diff --git a/doc/rfcs/001-allow-guests-to-open-multiple-remote-buffers.md b/doc/rfcs/001-allow-guests-to-open-multiple-remote-buffers.md index 52da9972..6d67548d 100644 --- a/doc/rfcs/001-allow-guests-to-open-multiple-remote-buffers.md +++ b/doc/rfcs/001-allow-guests-to-open-multiple-remote-buffers.md @@ -2,7 +2,9 @@ ## Status -This is a proposal and is not yet implemented. +This is a proposal and is not yet fully implemented. + +[#262](https://github.com/atom/teletype/pull/262) implements the majority of this proposal, and the remaining functionality is planned for future pull requests. See [#262](https://github.com/atom/teletype/pull/262) for details. ## Summary @@ -32,7 +34,12 @@ When a host closes a buffer, it will be removed from all guest portals. If anoth You can follow any other guest participating in the host's workspace in the exact same way. If they move between buffers, you will follow them. The host does not enjoy any special privilege with respect to the ability to be followed between different files. -When a participant is viewing a different buffer than you are viewing, that participant's avatar appears in the bottom right with an icon (e.g., https://octicons.github.com/icon/link-external) indicating that they're working on a different buffer. +When viewing an editor associated with a portal, each participant sees the avatars for the other portal participants (just as they did prior to this RFC). As a host, when your active pane item is a local editor (i.e., an editor that you're sharing in your portal), the editor shows the avatars for the other portal participants. As a guest, when your active pane item is a remote editor (i.e., an editor that you're viewing from the host's portal), the editor shows the avatars for the other portal participants. The location of each avatar within the editor indicates the relative position of that participant: +- Top-right: Participants in the same editor but in a row above your viewport +- Middle-right: Participants in the same editor and inside your viewport +- Bottom-right: Participants in the same editor but in a row below your viewport or a column outside of your viewport +- Bottom-right with TBD icon 1: Participants in a different editor in the portal +- Bottom-right with TBD icon 2: Participants in a different pane item not associated with the portal Editors for remote buffers are *only* automatically opened when you are following another collaborator. If you are not following someone, no editors are automatically opened. When you start following another collaborator again, an editor will be automatically opened based on their location. You can also open any buffer in the host's workspace directly by navigating to it... diff --git a/lib/buffer-binding.js b/lib/buffer-binding.js index a3a78ed7..3c184edd 100644 --- a/lib/buffer-binding.js +++ b/lib/buffer-binding.js @@ -4,13 +4,18 @@ function doNothing () {} module.exports = class BufferBinding { - constructor ({buffer, didDispose}) { + constructor ({buffer, isHost, didDispose}) { this.buffer = buffer + this.isHost = isHost this.emitDidDispose = didDispose || doNothing this.pendingChanges = [] + this.disposed = false } dispose () { + if (this.disposed) return + + this.disposed = true this.buffer.restoreDefaultHistoryProvider(this.bufferProxy.getHistory(this.buffer.maxUndoEntries)) this.buffer = null if (this.bufferDestroySubscription) this.bufferDestroySubscription.dispose() @@ -24,7 +29,13 @@ class BufferBinding { this.pushChange(this.pendingChanges.shift()) } this.pendingChanges = null - this.bufferDestroySubscription = this.buffer.onDidDestroy(() => bufferProxy.dispose()) + this.bufferDestroySubscription = this.buffer.onDidDestroy(() => { + if (this.isHost) { + bufferProxy.dispose() + } else { + this.dispose() + } + }) } setText (text) { diff --git a/lib/editor-binding.js b/lib/editor-binding.js index e2e668fe..3f9e692b 100644 --- a/lib/editor-binding.js +++ b/lib/editor-binding.js @@ -1,20 +1,17 @@ /* global ResizeObserver */ const path = require('path') -const {Range, Disposable, CompositeDisposable} = require('atom') +const {Range, Emitter, Disposable, CompositeDisposable} = require('atom') const normalizeURI = require('./normalize-uri') const {FollowState} = require('@atom/teletype-client') -const SitePositionsComponent = require('./site-positions-component') - -function doNothing () {} module.exports = class EditorBinding { - constructor ({editor, portal, isHost, didDispose}) { + constructor ({editor, portal, isHost}) { this.editor = editor this.portal = portal this.isHost = isHost - this.emitDidDispose = didDispose || doNothing + this.emitter = new Emitter() this.selectionsMarkerLayer = this.editor.selectionsMarkerLayer.bufferMarkerLayer this.markerLayersBySiteId = new Map() this.markersByLayerAndId = new WeakMap() @@ -24,6 +21,9 @@ class EditorBinding { } dispose () { + if (this.disposed) return + + this.disposed = true this.subscriptions.dispose() this.markerLayersBySiteId.forEach((l) => l.destroy()) @@ -31,11 +31,8 @@ class EditorBinding { if (!this.isHost) this.restoreOriginalEditorMethods(this.editor) if (this.localCursorLayerDecoration) this.localCursorLayerDecoration.destroy() - this.aboveViewportSitePositionsComponent.destroy() - this.insideViewportSitePositionsComponent.destroy() - this.outsideViewportSitePositionsComponent.destroy() - - this.emitDidDispose() + this.emitter.emit('did-dispose') + this.emitter.dispose() } setEditorProxy (editorProxy) { @@ -44,6 +41,7 @@ class EditorBinding { this.editor.onDidDestroy(() => this.editorProxy.dispose()) } else { this.monkeyPatchEditorMethods(this.editor, this.editorProxy) + this.editor.onDidDestroy(() => this.dispose()) } this.localCursorLayerDecoration = this.editor.decorateMarkerLayer( @@ -59,15 +57,7 @@ class EditorBinding { this.subscriptions.add(this.editor.element.onDidChangeScrollTop(this.editorDidChangeScrollTop.bind(this))) this.subscriptions.add(this.editor.element.onDidChangeScrollLeft(this.editorDidChangeScrollLeft.bind(this))) this.subscriptions.add(subscribeToResizeEvents(this.editor.element, this.editorDidResize.bind(this))) - this.relayLocalSelections(true) - - this.aboveViewportSitePositionsComponent = this.buildSitePositionsComponent('upper-right') - this.insideViewportSitePositionsComponent = this.buildSitePositionsComponent('middle-right') - this.outsideViewportSitePositionsComponent = this.buildSitePositionsComponent('lower-right') - - this.editor.element.appendChild(this.aboveViewportSitePositionsComponent.element) - this.editor.element.appendChild(this.insideViewportSitePositionsComponent.element) - this.editor.element.appendChild(this.outsideViewportSitePositionsComponent.element) + this.relayLocalSelections() } monkeyPatchEditorMethods (editor, editorProxy) { @@ -82,9 +72,13 @@ class EditorBinding { editor.copy = () => null editor.serialize = () => null editor.isRemote = true + + let remoteEditorCountForBuffer = buffer.remoteEditorCount || 0 + buffer.remoteEditorCount = ++remoteEditorCountForBuffer buffer.getPath = () => `${uriPrefix}:${bufferURI}` buffer.save = () => {} buffer.isModified = () => false + editor.element.classList.add('teletype-RemotePaneItem') } @@ -98,22 +92,27 @@ class EditorBinding { delete editor.copy delete editor.serialize delete editor.isRemote - delete buffer.getPath - delete buffer.save - delete buffer.isModified + + buffer.remoteEditorCount-- + if (buffer.remoteEditorCount === 0) { + delete buffer.remoteEditorCount + delete buffer.getPath + delete buffer.save + delete buffer.isModified + } editor.element.classList.remove('teletype-RemotePaneItem') editor.emitter.emit('did-change-title', editor.getTitle()) } - observeMarker (marker, relayLocalSelections = true) { + observeMarker (marker, relay = true) { const didChangeDisposable = marker.onDidChange(({textChanged}) => { if (textChanged) { if (marker.getRange().isEmpty()) marker.clearTail() } else { - this.editorProxy.updateSelections({ + this.updateSelections({ [marker.id]: getSelectionState(marker) - }, this.preserveFollowState) + }) } }) const didDestroyDisposable = marker.onDidDestroy(() => { @@ -122,33 +121,50 @@ class EditorBinding { this.subscriptions.remove(didChangeDisposable) this.subscriptions.remove(didDestroyDisposable) - this.editorProxy.updateSelections({ + this.updateSelections({ [marker.id]: null - }, this.preserveFollowState) + }) }) this.subscriptions.add(didChangeDisposable) this.subscriptions.add(didDestroyDisposable) - if (relayLocalSelections) this.relayLocalSelections() + + if (relay) { + this.updateSelections({ + [marker.id]: getSelectionState(marker) + }) + } } async editorDidChangeScrollTop () { const {element} = this.editor await element.component.getNextUpdatePromise() - this.updateActivePositions(this.positionsBySiteId) this.editorProxy.didScroll() + this.emitter.emit('did-scroll') } async editorDidChangeScrollLeft () { const {element} = this.editor await element.component.getNextUpdatePromise() - this.updateActivePositions(this.positionsBySiteId) this.editorProxy.didScroll() + this.emitter.emit('did-scroll') } async editorDidResize () { const {element} = this.editor await element.component.getNextUpdatePromise() - this.updateActivePositions(this.positionsBySiteId) + this.emitter.emit('did-resize') + } + + onDidDispose (callback) { + return this.emitter.on('did-dispose', callback) + } + + onDidScroll (callback) { + return this.emitter.on('did-scroll', callback) + } + + onDidResize (callback) { + return this.emitter.on('did-resize', callback) } updateSelectionsForSiteId (siteId, selections) { @@ -198,8 +214,10 @@ class EditorBinding { } } - isPositionVisible (bufferPosition) { - return this.getDirectionFromViewportToPosition(bufferPosition) === 'inside' + isScrollNeededToViewPosition (position) { + const isPositionVisible = this.getDirectionFromViewportToPosition(position) === 'inside' + const isEditorAttachedToDOM = document.body.contains(this.editor.element) + return isEditorAttachedToDOM && !isPositionVisible } updateTether (state, position) { @@ -207,10 +225,7 @@ class EditorBinding { if (state === FollowState.RETRACTED) { this.editor.destroyFoldsIntersectingBufferRange(Range(position, position)) - - this.preserveFollowState = true - this.editor.setCursorBufferPosition(position) - this.preserveFollowState = false + this.batchMarkerUpdates(() => this.editor.setCursorBufferPosition(position)) localCursorDecorationProperties.style = {opacity: 0} } else { @@ -220,36 +235,6 @@ class EditorBinding { this.localCursorLayerDecoration.setProperties(localCursorDecorationProperties) } - updateActivePositions (positionsBySiteId) { - const aboveViewportSiteIds = [] - const insideViewportSiteIds = [] - const outsideViewportSiteIds = [] - const followedSiteId = this.editorProxy.getFollowedSiteId() - - for (let siteId in positionsBySiteId) { - siteId = parseInt(siteId) - const position = positionsBySiteId[siteId] - switch (this.getDirectionFromViewportToPosition(position)) { - case 'upward': - aboveViewportSiteIds.push(siteId) - break - case 'inside': - insideViewportSiteIds.push(siteId) - break - case 'downward': - case 'leftward': - case 'rightward': - outsideViewportSiteIds.push(siteId) - break - } - } - - this.aboveViewportSitePositionsComponent.update({siteIds: aboveViewportSiteIds, followedSiteId}) - this.insideViewportSitePositionsComponent.update({siteIds: insideViewportSiteIds, followedSiteId}) - this.outsideViewportSitePositionsComponent.update({siteIds: outsideViewportSiteIds, followedSiteId}) - this.positionsBySiteId = positionsBySiteId - } - getDirectionFromViewportToPosition (bufferPosition) { const {element} = this.editor if (!document.contains(element)) return @@ -278,23 +263,31 @@ class EditorBinding { this.markersByLayerAndId.delete(markerLayer) } - relayLocalSelections (initialUpdate = false) { + relayLocalSelections () { const selectionUpdates = {} const selectionMarkers = this.selectionsMarkerLayer.getMarkers() for (let i = 0; i < selectionMarkers.length; i++) { const marker = selectionMarkers[i] selectionUpdates[marker.id] = getSelectionState(marker) } - this.editorProxy.updateSelections(selectionUpdates, initialUpdate) + this.editorProxy.updateSelections(selectionUpdates, {initialUpdate: true}) } - buildSitePositionsComponent (position) { - return new SitePositionsComponent({ - position, - displayedParticipantsCount: 3, - portal: this.portal, - onSelectSiteId: this.toggleFollowingForSiteId.bind(this) - }) + batchMarkerUpdates (fn) { + this.batchedMarkerUpdates = {} + this.isBatchingMarkerUpdates = true + fn() + this.isBatchingMarkerUpdates = false + this.editorProxy.updateSelections(this.batchedMarkerUpdates) + this.batchedMarkerUpdates = null + } + + updateSelections (update) { + if (this.isBatchingMarkerUpdates) { + Object.assign(this.batchedMarkerUpdates, update) + } else { + this.editorProxy.updateSelections(update) + } } toggleFollowingForSiteId (siteId) { diff --git a/lib/guest-portal-binding.js b/lib/guest-portal-binding.js index fc045c27..b26aa29c 100644 --- a/lib/guest-portal-binding.js +++ b/lib/guest-portal-binding.js @@ -1,8 +1,9 @@ -const {Emitter, TextEditor, TextBuffer} = require('atom') -const {Errors} = require('@atom/teletype-client') +const {CompositeDisposable, Emitter, TextEditor, TextBuffer} = require('atom') +const {Errors, FollowState} = require('@atom/teletype-client') const BufferBinding = require('./buffer-binding') const EditorBinding = require('./editor-binding') const EmptyPortalPaneItem = require('./empty-portal-pane-item') +const SitePositionsController = require('./site-positions-controller') module.exports = class GuestPortalBinding { @@ -12,12 +13,14 @@ class GuestPortalBinding { this.workspace = workspace this.notificationManager = notificationManager this.emitDidDispose = didDispose - this.activePaneItem = null + this.lastActivePaneItem = null this.editorBindingsByEditorProxy = new Map() this.bufferBindingsByBufferProxy = new Map() - this.addedPaneItems = new WeakSet() + this.editorProxiesByEditor = new WeakMap() this.emitter = new Emitter() - this.lastSetActiveEditorProxyPromise = Promise.resolve() + this.subscriptions = new CompositeDisposable() + this.lastEditorProxyChangePromise = Promise.resolve() + this.shouldRelayActiveEditorChanges = true } async initialize () { @@ -25,7 +28,14 @@ class GuestPortalBinding { this.portal = await this.client.joinPortal(this.portalId) if (!this.portal) return false - this.portal.setDelegate(this) + this.sitePositionsController = new SitePositionsController({portal: this.portal, workspace: this.workspace}) + this.sitePositionsController.show() + + await this.portal.setDelegate(this) + await this.toggleEmptyPortalPaneItem() + + this.subscriptions.add(this.workspace.onDidChangeActivePaneItem(this.didChangeActivePaneItem.bind(this))) + this.subscriptions.add(this.workspace.onDidDestroyPaneItem(this.didDestroyPaneItem.bind(this))) return true } catch (error) { this.didFailToJoin(error) @@ -34,9 +44,10 @@ class GuestPortalBinding { } dispose () { - if (this.activePaneItemDestroySubscription) this.activePaneItemDestroySubscription.dispose() - if (this.activePaneItem) this.activePaneItem.destroy() + this.subscriptions.dispose() + this.sitePositionsController.destroy() if (this.emptyPortalItem) this.emptyPortalItem.destroy() + this.emitDidDispose() } @@ -54,17 +65,63 @@ class GuestPortalBinding { this.emitter.emit('did-change') } - async setActiveEditorProxy (editorProxy) { - this.lastSetActiveEditorProxyPromise = this.lastSetActiveEditorProxyPromise.then(async () => { - if (editorProxy == null) { - await this.replaceActivePaneItem(this.getEmptyPortalPaneItem()) - } else { - const editor = this.findOrCreateEditorForEditorProxy(editorProxy) - await this.replaceActivePaneItem(editor) + addEditorProxy (editorProxy) { + // TODO Implement in order to allow guests to open any editor that's in the host's workspace + } + + removeEditorProxy (editorProxy) { + this.lastEditorProxyChangePromise = this.lastEditorProxyChangePromise.then(async () => { + const editorBinding = this.editorBindingsByEditorProxy.get(editorProxy) + if (editorBinding) { + editorBinding.dispose() + if (this.editorBindingsByEditorProxy.size === 0) { + this.portal.follow(1) + } + + await this.toggleEmptyPortalPaneItem() + + const isRetracted = this.portal.resolveFollowState() === FollowState.RETRACTED + this.shouldRelayActiveEditorChanges = !isRetracted + this.lastDestroyedEditorWasRemovedByHost = true + editorBinding.editor.destroy() + this.lastDestroyedEditorWasRemovedByHost = false + this.shouldRelayActiveEditorChanges = true } }) - return this.lastSetActiveEditorProxyPromise + return this.lastEditorProxyChangePromise + } + + updateActivePositions (positionsBySiteId) { + this.sitePositionsController.updateActivePositions(positionsBySiteId) + } + + updateTether (followState, editorProxy, position) { + if (editorProxy) { + this.lastEditorProxyChangePromise = this.lastEditorProxyChangePromise.then(() => + this._updateTether(followState, editorProxy, position) + ) + } + + return this.lastEditorProxyChangePromise + } + + // Private + async _updateTether (followState, editorProxy, position) { + if (followState === FollowState.RETRACTED) { + const editor = this.findOrCreateEditorForEditorProxy(editorProxy) + this.shouldRelayActiveEditorChanges = false + await this.openPaneItem(editor) + this.shouldRelayActiveEditorChanges = true + await this.toggleEmptyPortalPaneItem() + } else { + this.editorBindingsByEditorProxy.forEach((b) => b.updateTether(followState)) + } + + const editorBinding = this.editorBindingsByEditorProxy.get(editorProxy) + if (editorBinding && position) { + editorBinding.updateTether(followState, position) + } } // Private @@ -80,12 +137,19 @@ class GuestPortalBinding { editorBinding = new EditorBinding({ editor, portal: this.portal, - isHost: false, - didDispose: () => this.editorBindingsByEditorProxy.delete(editorProxy) + isHost: false }) editorBinding.setEditorProxy(editorProxy) editorProxy.setDelegate(editorBinding) + this.editorBindingsByEditorProxy.set(editorProxy, editorBinding) + this.editorProxiesByEditor.set(editor, editorProxy) + editorBinding.onDidDispose(() => { + this.editorProxiesByEditor.delete(editor) + this.editorBindingsByEditorProxy.delete(editorProxy) + }) + + this.sitePositionsController.addEditorBinding(editorBinding) } return editor } @@ -100,6 +164,7 @@ class GuestPortalBinding { buffer = new TextBuffer() bufferBinding = new BufferBinding({ buffer, + isHost: false, didDispose: () => this.bufferBindingsByBufferProxy.delete(bufferProxy) }) bufferBinding.setBufferProxy(bufferProxy) @@ -109,11 +174,22 @@ class GuestPortalBinding { return buffer } + // Private + async toggleEmptyPortalPaneItem () { + const emptyPortalItem = this.getEmptyPortalPaneItem() + const pane = this.workspace.paneForItem(emptyPortalItem) + if (this.editorBindingsByEditorProxy.size === 0) { + if (!pane) await this.openPaneItem(emptyPortalItem) + } else { + if (pane) emptyPortalItem.destroy() + } + } + activate () { - const activePaneItem = this.getActivePaneItem() - const pane = this.workspace.paneForItem(activePaneItem) - if (pane && activePaneItem) { - pane.activateItem(activePaneItem) + const paneItem = this.lastActivePaneItem + const pane = this.workspace.paneForItem(paneItem) + if (pane && paneItem) { + pane.activateItem(paneItem) pane.activate() } } @@ -140,7 +216,6 @@ class GuestPortalBinding { description: 'Your host stopped sharing their editor.', dismissable: true }) - this.activePaneItem = null } hostDidLoseConnection () { @@ -151,34 +226,57 @@ class GuestPortalBinding { ), dismissable: true }) - this.activePaneItem = null } leave () { + this.editorBindingsByEditorProxy.forEach((binding) => { + binding.editor.destroy() + }) + if (this.portal) this.portal.dispose() } - async replaceActivePaneItem (newActivePaneItem) { + async openPaneItem (newActivePaneItem) { this.newActivePaneItem = newActivePaneItem + await this.workspace.open(newActivePaneItem, {searchAllPanes: true}) + this.lastActivePaneItem = this.newActivePaneItem + this.newActivePaneItem = null + } + + didChangeActivePaneItem (paneItem) { + const editorProxy = this.editorProxiesByEditor.get(paneItem) - if (this.activePaneItem) { - const pane = this.workspace.paneForItem(this.activePaneItem) - const index = pane.getItems().indexOf(this.activePaneItem) - pane.addItem(newActivePaneItem, {index, moved: this.addedPaneItems.has(newActivePaneItem)}) - pane.removeItem(this.activePaneItem) + if (editorProxy || paneItem === this.getEmptyPortalPaneItem()) { + this.sitePositionsController.show() } else { - await this.workspace.open(newActivePaneItem) + this.sitePositionsController.hide() } - this.addedPaneItems.add(newActivePaneItem) - this.activePaneItem = this.newActivePaneItem - if (this.activePaneItemDestroySubscription) this.activePaneItemDestroySubscription.dispose() - this.activePaneItemDestroySubscription = this.activePaneItem.onDidDestroy(this.leave.bind(this)) - this.newActivePaneItem = null + if (this.shouldRelayActiveEditorChanges && paneItem !== this.getEmptyPortalPaneItem()) { + this.portal.activateEditorProxy(editorProxy) + } + } + + didDestroyPaneItem () { + const emptyPortalItem = this.getEmptyPortalPaneItem() + const hasNoPortalPaneItem = this.workspace.getPaneItems().every((item) => ( + item !== emptyPortalItem && !this.editorProxiesByEditor.has(item) + )) + const lastDestroyedEditorWasClosedManually = !this.lastDestroyedEditorWasRemovedByHost + if (hasNoPortalPaneItem && lastDestroyedEditorWasClosedManually) { + this.leave() + } + } + + hasPaneItem (paneItem) { + return ( + paneItem === this.getEmptyPortalPaneItem() || + this.editorProxiesByEditor.has(paneItem) + ) } getActivePaneItem () { - return this.newActivePaneItem ? this.newActivePaneItem : this.activePaneItem + return this.newActivePaneItem || this.workspace.getActivePaneItem() } getEmptyPortalPaneItem () { diff --git a/lib/host-portal-binding.js b/lib/host-portal-binding.js index b18ed761..31a846d1 100644 --- a/lib/host-portal-binding.js +++ b/lib/host-portal-binding.js @@ -1,7 +1,9 @@ const path = require('path') const {CompositeDisposable, Emitter} = require('atom') +const {FollowState} = require('@atom/teletype-client') const BufferBinding = require('./buffer-binding') const EditorBinding = require('./editor-binding') +const SitePositionsController = require('./site-positions-controller') module.exports = class HostPortalBinding { @@ -10,9 +12,11 @@ class HostPortalBinding { this.workspace = workspace this.notificationManager = notificationManager this.editorBindingsByEditor = new WeakMap() + this.editorBindingsByEditorProxy = new Map() this.bufferBindingsByBuffer = new WeakMap() this.disposables = new CompositeDisposable() this.emitter = new Emitter() + this.lastUpdateTetherPromise = Promise.resolve() this.didDispose = didDispose } @@ -21,10 +25,13 @@ class HostPortalBinding { this.portal = await this.client.createPortal() if (!this.portal) return false + this.sitePositionsController = new SitePositionsController({portal: this.portal, workspace: this.workspace}) + this.portal.setDelegate(this) - this.disposables.add(this.workspace.observeActiveTextEditor( - this.didChangeActiveTextEditor.bind(this) - )) + this.disposables.add( + this.workspace.observeActiveTextEditor(this.didChangeActiveTextEditor.bind(this)), + this.workspace.onDidDestroyPaneItem(this.didDestroyPaneItem.bind(this)) + ) this.workspace.getElement().classList.add('teletype-Host') return true @@ -39,6 +46,7 @@ class HostPortalBinding { dispose () { this.workspace.getElement().classList.remove('teletype-Host') + this.sitePositionsController.destroy() this.disposables.dispose() this.didDispose() } @@ -64,41 +72,89 @@ class HostPortalBinding { } didChangeActiveTextEditor (editor) { - if (editor == null || editor.isRemote) { - this.portal.setActiveEditorProxy(null) - return + if (editor && !editor.isRemote) { + const editorProxy = this.findOrCreateEditorProxyForEditor(editor) + this.portal.activateEditorProxy(editorProxy) + this.sitePositionsController.show() + } else { + this.portal.activateEditorProxy(null) + this.sitePositionsController.hide() } + } - let editorBinding = this.editorBindingsByEditor.get(editor) - if (!editorBinding) { - const buffer = editor.getBuffer() - - let bufferBinding = this.bufferBindingsByBuffer.get(buffer) - let bufferProxy = bufferBinding ? bufferBinding.bufferProxy : null - if (!bufferBinding) { - bufferBinding = new BufferBinding({buffer}) - bufferProxy = this.portal.createBufferProxy({ - uri: this.getBufferProxyURI(buffer), - history: buffer.getHistory() - }) - bufferBinding.setBufferProxy(bufferProxy) - bufferProxy.setDelegate(bufferBinding) - - this.bufferBindingsByBuffer.set(buffer, bufferBinding) - } + updateActivePositions (positionsBySiteId) { + this.sitePositionsController.updateActivePositions(positionsBySiteId) + } + + updateTether (followState, editorProxy, position) { + if (editorProxy) { + this.lastUpdateTetherPromise = this.lastUpdateTetherPromise.then(() => + this._updateTether(followState, editorProxy, position) + ) + } + return this.lastUpdateTetherPromise + } + + // Private + async _updateTether (followState, editorProxy, position) { + const editorBinding = this.editorBindingsByEditorProxy.get(editorProxy) + + if (followState === FollowState.RETRACTED) { + await this.workspace.open(editorBinding.editor, {searchAllPanes: true}) + if (position) editorBinding.updateTether(followState, position) + } else { + this.editorBindingsByEditorProxy.forEach((b) => b.updateTether(followState)) + } + } + + didDestroyPaneItem ({item}) { + const editorBinding = this.editorBindingsByEditor.get(item) + if (editorBinding) { + this.portal.removeEditorProxy(editorBinding.editorProxy) + } + } + + findOrCreateEditorProxyForEditor (editor) { + let editorBinding = this.editorBindingsByEditor.get(editor) + if (editorBinding) { + return editorBinding.editorProxy + } else { + const bufferProxy = this.findOrCreateBufferProxyForBuffer(editor.getBuffer()) + const editorProxy = this.portal.createEditorProxy({bufferProxy}) editorBinding = new EditorBinding({editor, portal: this.portal, isHost: true}) - const editorProxy = this.portal.createEditorProxy({ - bufferProxy, - selections: editor.selectionsMarkerLayer.bufferMarkerLayer.createSnapshot() - }) editorBinding.setEditorProxy(editorProxy) editorProxy.setDelegate(editorBinding) this.editorBindingsByEditor.set(editor, editorBinding) + this.editorBindingsByEditorProxy.set(editorProxy, editorBinding) + editorBinding.onDidDispose(() => { + this.editorBindingsByEditorProxy.delete(editorProxy) + }) + + this.sitePositionsController.addEditorBinding(editorBinding) + + return editorProxy } + } - this.portal.setActiveEditorProxy(editorBinding.editorProxy) + findOrCreateBufferProxyForBuffer (buffer) { + let bufferBinding = this.bufferBindingsByBuffer.get(buffer) + if (bufferBinding) { + return bufferBinding.bufferProxy + } else { + bufferBinding = new BufferBinding({buffer, isHost: true}) + const bufferProxy = this.portal.createBufferProxy({ + uri: this.getBufferProxyURI(buffer), + history: buffer.getHistory() + }) + bufferBinding.setBufferProxy(bufferProxy) + bufferProxy.setDelegate(bufferBinding) + + this.bufferBindingsByBuffer.set(buffer, bufferBinding) + + return bufferProxy + } } getBufferProxyURI (buffer) { diff --git a/lib/portal-binding-manager.js b/lib/portal-binding-manager.js index 548ab99f..c0435d08 100644 --- a/lib/portal-binding-manager.js +++ b/lib/portal-binding-manager.js @@ -110,7 +110,7 @@ class PortalBindingManager { const activePaneItem = this.workspace.getActivePaneItem() for (const [_, portalBindingPromise] of this.promisesByGuestPortalId) { // eslint-disable-line no-unused-vars const portalBinding = await portalBindingPromise - if (portalBinding.getActivePaneItem() === activePaneItem) { + if (portalBinding.hasPaneItem(activePaneItem)) { return portalBinding } } diff --git a/lib/site-positions-controller.js b/lib/site-positions-controller.js new file mode 100644 index 00000000..89cba7d1 --- /dev/null +++ b/lib/site-positions-controller.js @@ -0,0 +1,107 @@ +const {CompositeDisposable} = require('atom') +const SitePositionsComponent = require('./site-positions-component') + +module.exports = +class SitePositionsController { + constructor ({portal, workspace}) { + this.portal = portal + this.workspace = workspace + this.subscriptions = new CompositeDisposable() + this.editorBindingsByEditorProxy = new WeakMap() + this.visible = false + this.aboveViewportSitePositionsComponent = this.buildSitePositionsComponent('upper-right') + this.insideViewportSitePositionsComponent = this.buildSitePositionsComponent('middle-right') + this.outsideViewportSitePositionsComponent = this.buildSitePositionsComponent('lower-right') + this.positionsBySiteId = {} + } + + destroy () { + this.subscriptions.dispose() + this.aboveViewportSitePositionsComponent.destroy() + this.insideViewportSitePositionsComponent.destroy() + this.outsideViewportSitePositionsComponent.destroy() + } + + show () { + const containerElement = this.workspace.getCenter().paneContainer.getElement() + containerElement.appendChild(this.aboveViewportSitePositionsComponent.element) + containerElement.appendChild(this.insideViewportSitePositionsComponent.element) + containerElement.appendChild(this.outsideViewportSitePositionsComponent.element) + this.visible = true + } + + hide () { + this.aboveViewportSitePositionsComponent.element.remove() + this.insideViewportSitePositionsComponent.element.remove() + this.outsideViewportSitePositionsComponent.element.remove() + this.visible = false + } + + addEditorBinding (editorBinding) { + this.editorBindingsByEditorProxy.set(editorBinding.editorProxy, editorBinding) + + const didResizeSubscription = editorBinding.onDidResize(() => this.updateActivePositions(this.positionsBySiteId)) + const didScrollSubscription = editorBinding.onDidScroll(() => this.updateActivePositions(this.positionsBySiteId)) + this.subscriptions.add(didResizeSubscription) + this.subscriptions.add(didScrollSubscription) + + editorBinding.onDidDispose(() => { + didResizeSubscription.dispose() + didScrollSubscription.dispose() + this.editorBindingsByEditorProxy.delete(editorBinding.editorProxy) + }) + } + + updateActivePositions (positionsBySiteId) { + const aboveViewportSiteIds = [] + const insideViewportSiteIds = [] + const outsideViewportSiteIds = [] + + for (let siteId in positionsBySiteId) { + siteId = parseInt(siteId) + if (siteId === this.portal.siteId) continue + + const {editorProxy, position} = positionsBySiteId[siteId] + const editorBinding = this.editorBindingsByEditorProxy.get(editorProxy) + if (position && editorBinding && editorBinding.editor === this.workspace.getActivePaneItem()) { + switch (editorBinding.getDirectionFromViewportToPosition(position)) { + case 'upward': + aboveViewportSiteIds.push(siteId) + break + case 'inside': + insideViewportSiteIds.push(siteId) + break + case 'downward': + case 'leftward': + case 'rightward': + outsideViewportSiteIds.push(siteId) + break + } + } else { + outsideViewportSiteIds.push(siteId) + } + } + + const followedSiteId = this.portal.getFollowedSiteId() + this.aboveViewportSitePositionsComponent.update({siteIds: aboveViewportSiteIds, followedSiteId}) + this.insideViewportSitePositionsComponent.update({siteIds: insideViewportSiteIds, followedSiteId}) + this.outsideViewportSitePositionsComponent.update({siteIds: outsideViewportSiteIds, followedSiteId}) + this.positionsBySiteId = positionsBySiteId + } + + // Private + buildSitePositionsComponent (position) { + return new SitePositionsComponent({ + position, + displayedParticipantsCount: 3, + portal: this.portal, + onSelectSiteId: (siteId) => { + if (siteId === this.portal.getFollowedSiteId()) { + this.portal.unfollow() + } else { + this.portal.follow(siteId) + } + } + }) + } +} diff --git a/lib/teletype-package.js b/lib/teletype-package.js index 45a55246..3eb0d95f 100644 --- a/lib/teletype-package.js +++ b/lib/teletype-package.js @@ -50,6 +50,9 @@ class TeletypePackage { this.subscriptions.add(this.commandRegistry.add('atom-workspace', { 'teletype:join-portal': () => this.joinPortal() })) + this.subscriptions.add(this.commandRegistry.add('teletype-RemotePaneItem', { + 'teletype:leave-portal': () => this.leavePortal() + })) this.subscriptions.add(this.commandRegistry.add('atom-workspace.teletype-Host', { 'teletype:close-portal': () => this.closeHostPortal() })) @@ -101,6 +104,14 @@ class TeletypePackage { hostPortalBinding.close() } + async leavePortal () { + this.showPopover() + + const manager = await this.getPortalBindingManager() + const guestPortalBinding = await manager.getActiveGuestPortalBinding() + guestPortalBinding.leave() + } + async consumeStatusBar (statusBar) { const teletypeClient = await this.getClient() const portalBindingManager = await this.getPortalBindingManager() diff --git a/package.json b/package.json index 4350028c..0934237f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "temp": "^0.8.3" }, "dependencies": { - "@atom/teletype-client": "^0.28.2", + "@atom/teletype-client": "^0.29.0", "etch": "^0.12.6" }, "consumedServices": { diff --git a/styles/teletype.less b/styles/teletype.less index 851b7a79..192a7714 100644 --- a/styles/teletype.less +++ b/styles/teletype.less @@ -369,7 +369,7 @@ } &.upper-right { - top: @corner-margin; + top: @corner-margin * 3; display: flex; flex-direction: row; justify-content: flex-end; diff --git a/test/buffer-binding.test.js b/test/buffer-binding.test.js index 69821911..77530aaf 100644 --- a/test/buffer-binding.test.js +++ b/test/buffer-binding.test.js @@ -42,10 +42,42 @@ suite('BufferBinding', function () { assert.equal(bufferProxy.text, 'hello\nworld') }) + suite('destroying the buffer', () => { + test('on the host, disposes the underlying buffer proxy', () => { + const buffer = new TextBuffer('') + const binding = new BufferBinding({buffer, isHost: true}) + const bufferProxy = new FakeBufferProxy(binding, buffer.getText()) + binding.setBufferProxy(bufferProxy) + + buffer.destroy() + assert(bufferProxy.disposed) + }) + + test('on guests, disposes the buffer binding', () => { + const buffer = new TextBuffer('') + const binding = new BufferBinding({buffer, isHost: false}) + const bufferProxy = new FakeBufferProxy(binding, buffer.getText()) + binding.setBufferProxy(bufferProxy) + + buffer.destroy() + assert(binding.disposed) + assert(!bufferProxy.disposed) + }) + }) + class FakeBufferProxy { constructor (delegate, text) { this.delegate = delegate this.text = text + this.disposed = false + } + + dispose () { + this.disposed = true + } + + getHistory () { + return {undoStack: [], redoStack: [], nextCheckpointId: 1} } setTextInRange (oldStart, oldEnd, newText) { diff --git a/test/editor-binding.test.js b/test/editor-binding.test.js index 40b36f56..40a89e8b 100644 --- a/test/editor-binding.test.js +++ b/test/editor-binding.test.js @@ -5,6 +5,13 @@ const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js' const {TextEditor, TextBuffer, Range} = require('atom') const EditorBinding = require('../lib/editor-binding') const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') +const {loadPackageStyleSheets} = require('./helpers/ui-helpers') +const { + setEditorHeightInLines, + setEditorWidthInChars, + setEditorScrollTopInLines, + setEditorScrollLeftInChars +} = require('./helpers/editor-helpers') const {FollowState} = require('@atom/teletype-client') suite('EditorBinding', function () { @@ -15,11 +22,7 @@ suite('EditorBinding', function () { setup(() => { // Load the editor default styles by instantiating a new AtomEnvironment. const environment = buildAtomEnvironment() - // Load also package style sheets, so that additional UI elements are styled - // correctly. - const packageStyleSheetPath = path.join(__dirname, '..', 'styles', 'teletype.less') - const compiledStyleSheet = environment.themes.loadStylesheet(packageStyleSheetPath) - environment.styles.addStyleSheet(compiledStyleSheet) + loadPackageStyleSheets(environment) // Position editor absolutely to prevent its size from being affected by the // window size of the test runner. We also give it an initial width and // height so that the editor component can perform initial measurements. @@ -298,6 +301,29 @@ suite('EditorBinding', function () { }) }) + suite('destroying the editor', () => { + test('on the host, disposes the underlying editor proxy', () => { + const editor = new TextEditor() + const binding = new EditorBinding({editor, isHost: true, portal: new FakePortal()}) + const editorProxy = new FakeEditorProxy(binding) + binding.setEditorProxy(editorProxy) + + editor.destroy() + assert(editorProxy.disposed) + }) + + test('on guests, disposes the editor binding', () => { + const editor = new TextEditor() + const binding = new EditorBinding({editor, isHost: false, portal: new FakePortal()}) + const editorProxy = new FakeEditorProxy(binding) + binding.setEditorProxy(editorProxy) + + editor.destroy() + assert(binding.disposed) + assert(!editorProxy.disposed) + }) + }) + suite('guest editor binding', () => { test('overrides the editor methods when setting the proxy, and restores them on dispose', () => { const buffer = new TextBuffer({text: SAMPLE_TEXT}) @@ -347,80 +373,15 @@ suite('EditorBinding', function () { assert.deepEqual(getCursorClasses(editor), []) }) - test('showing the active position of other collaborators', async () => { + test('isScrollNeededToViewPosition(position)', async () => { const editor = new TextEditor({autoHeight: false}) - editor.setText(SAMPLE_TEXT) - const binding = new EditorBinding({editor, portal: new FakePortal()}) const editorProxy = new FakeEditorProxy(binding) binding.setEditorProxy(editorProxy) - const { - aboveViewportSitePositionsComponent, - insideViewportSitePositionsComponent, - outsideViewportSitePositionsComponent - } = binding - assert(editor.element.contains(aboveViewportSitePositionsComponent.element)) - assert(editor.element.contains(insideViewportSitePositionsComponent.element)) - assert(editor.element.contains(outsideViewportSitePositionsComponent.element)) - - attachToDOM(editor.element) - - await setEditorHeightInLines(editor, 3) - await setEditorWidthInChars(editor, 5) - await setEditorScrollTopInLines(editor, 5) - await setEditorScrollLeftInChars(editor, 5) - - binding.updateActivePositions({ - 1: {row: 2, column: 5}, // collaborator above visible area - 2: {row: 9, column: 5}, // collaborator below visible area - 3: {row: 6, column: 1}, // collaborator to the left of visible area - 4: {row: 6, column: 15}, // collaborator to the right of visible area - 5: {row: 6, column: 6} // collaborator inside of visible area - }) - - assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, [1]) - assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [5]) - assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 3, 4]) - - await setEditorScrollLeftInChars(editor, 0) - - assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, [1]) - assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [3]) - assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 4, 5]) - - await setEditorScrollTopInLines(editor, 2) - - assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) - assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, []) - assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [1, 2, 3, 4, 5]) - - await setEditorHeightInLines(editor, 7) - - assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) - assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [3]) - assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [1, 2, 4, 5]) - - await setEditorWidthInChars(editor, 10) - - assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) - assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [1, 3, 5]) - assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 4]) - - // Selecting a site will follow them. - outsideViewportSitePositionsComponent.props.onSelectSiteId(2) - assert.equal(editorProxy.getFollowedSiteId(), 2) - - // Selecting the same site again will unfollow them. - outsideViewportSitePositionsComponent.props.onSelectSiteId(2) - assert.equal(editorProxy.getFollowedSiteId(), null) - }) - - test('isPositionVisible(position)', async () => { - const editor = new TextEditor({autoHeight: false}) - const binding = new EditorBinding({editor, portal: new FakePortal()}) - const editorProxy = new FakeEditorProxy(binding) - binding.setEditorProxy(editorProxy) + // If the editor is not yet attached to the DOM, scrolling isn't gonna help. + assert(!binding.isScrollNeededToViewPosition({row: 1, column: 0})) + assert(!binding.isScrollNeededToViewPosition({row: 0, column: 9})) attachToDOM(editor.element) await setEditorHeightInLines(editor, 4) @@ -428,9 +389,9 @@ suite('EditorBinding', function () { editor.setText('a pretty long line\n'.repeat(100)) - assert(binding.isPositionVisible({row: 1, column: 0})) - assert(!binding.isPositionVisible({row: 0, column: 9})) - assert(!binding.isPositionVisible({row: 6, column: 0})) + assert(!binding.isScrollNeededToViewPosition({row: 1, column: 0})) + assert(binding.isScrollNeededToViewPosition({row: 0, column: 9})) + assert(binding.isScrollNeededToViewPosition({row: 6, column: 0})) // Ensure text is rendered, so that we can scroll down/right. await editor.component.getNextUpdatePromise() @@ -438,9 +399,9 @@ suite('EditorBinding', function () { setEditorScrollTopInLines(editor, 5) setEditorScrollLeftInChars(editor, 5) - assert(binding.isPositionVisible({row: 6, column: 7})) - assert(!binding.isPositionVisible({row: 6, column: 0})) - assert(!binding.isPositionVisible({row: 3, column: 7})) + assert(!binding.isScrollNeededToViewPosition({row: 6, column: 7})) + assert(binding.isScrollNeededToViewPosition({row: 6, column: 0})) + assert(binding.isScrollNeededToViewPosition({row: 3, column: 7})) }) test('destroys folds intersecting the position of the leader', async () => { @@ -504,29 +465,6 @@ suite('EditorBinding', function () { attachedElements.push(element) document.body.insertBefore(element, document.body.firstChild) } - - async function setEditorHeightInLines (editor, lines) { - editor.element.style.height = editor.getLineHeightInPixels() * lines + 'px' - return editor.component.getNextUpdatePromise() - } - - async function setEditorWidthInChars (editor, chars) { - editor.element.style.width = - editor.component.getGutterContainerWidth() + - chars * editor.getDefaultCharWidth() + - 'px' - return editor.component.getNextUpdatePromise() - } - - async function setEditorScrollTopInLines (editor, lines) { - editor.element.setScrollTop(editor.getLineHeightInPixels() * lines) - return editor.component.getNextUpdatePromise() - } - - async function setEditorScrollLeftInChars (editor, chars) { - editor.element.setScrollLeft(editor.getDefaultCharWidth() * chars) - return editor.component.getNextUpdatePromise() - } }) class FakeEditorProxy { @@ -535,6 +473,11 @@ class FakeEditorProxy { this.bufferProxy = {uri: 'fake-buffer-proxy-uri'} this.selections = {} this.siteId = (siteId == null) ? 1 : siteId + this.disposed = false + } + + dispose () { + this.disposed = true } didScroll () {} @@ -549,24 +492,6 @@ class FakeEditorProxy { } } } - - follow (siteId) { - this.followedSiteId = siteId - this.followState = FollowState.RETRACTED - } - - unfollow () { - this.followedSiteId = null - this.followState = FollowState.DISCONNECTED - } - - getFollowedSiteId () { - if (this.followState === FollowState.DISCONNECTED) { - return null - } else { - return this.followedSiteId - } - } } class FakePortal { diff --git a/test/guest-portal-binding.test.js b/test/guest-portal-binding.test.js index 03c3519d..c83a1da4 100644 --- a/test/guest-portal-binding.test.js +++ b/test/guest-portal-binding.test.js @@ -1,7 +1,10 @@ const assert = require('assert') const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') -const {TeletypeClient} = require('@atom/teletype-client') +const {FollowState, TeletypeClient} = require('@atom/teletype-client') +const FakePortal = require('./helpers/fake-portal') +const FakeEditorProxy = require('./helpers/fake-editor-proxy') const GuestPortalBinding = require('../lib/guest-portal-binding') +const EmptyPortalPaneItem = require('../lib/empty-portal-pane-item') suite('GuestPortalBinding', () => { teardown(async () => { @@ -27,19 +30,12 @@ suite('GuestPortalBinding', () => { }) test('showing notifications when sites join or leave', async () => { - const stubPubSubGateway = {} - const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) - const portal = { - setDelegate (delegate) { - this.delegate = delegate - }, - getSiteIdentity (siteId) { - return {login: 'site-' + siteId} + const portal = new FakePortal() + const client = { + joinPortal () { + return portal } } - client.joinPortal = function () { - return portal - } const atomEnv = buildAtomEnvironment() const portalBinding = buildGuestPortalBinding(client, atomEnv, 'portal-id') await portalBinding.initialize() @@ -60,12 +56,7 @@ suite('GuestPortalBinding', () => { test('switching the active editor in rapid succession', async () => { const stubPubSubGateway = {} const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) - const portal = { - getSiteIdentity (siteId) { - return {login: 'some-host'} - }, - dispose () {} - } + const portal = new FakePortal() client.joinPortal = function () { return portal } @@ -78,23 +69,146 @@ suite('GuestPortalBinding', () => { activePaneItemChangeEvents.push(item) }) - portalBinding.setActiveEditorProxy(buildEditorProxy('uri-1')) - portalBinding.setActiveEditorProxy(buildEditorProxy('uri-2')) - portalBinding.setActiveEditorProxy(null) - await portalBinding.setActiveEditorProxy(buildEditorProxy('uri-3')) + portalBinding.updateTether(FollowState.RETRACTED, new FakeEditorProxy('uri-1')) + portalBinding.updateTether(FollowState.RETRACTED, new FakeEditorProxy('uri-2')) + await portalBinding.updateTether(FollowState.RETRACTED, new FakeEditorProxy('uri-3')) assert.deepEqual( activePaneItemChangeEvents.map((i) => i.getTitle()), - ['@some-host: uri-1', '@some-host: uri-2', '@some-host: No Active File', '@some-host: uri-3'] + ['@site-1: uri-1', '@site-1: uri-2', '@site-1: uri-3'] ) assert.deepEqual( atomEnv.workspace.getPaneItems().map((i) => i.getTitle()), - ['@some-host: uri-3'] + ['@site-1: uri-1', '@site-1: uri-2', '@site-1: uri-3'] ) disposable.dispose() }) + test('switching the active editor to a remote editor that had been moved into a non-active pane', async () => { + const stubPubSubGateway = {} + const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) + client.joinPortal = () => new FakePortal() + const atomEnv = buildAtomEnvironment() + const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') + await portalBinding.initialize() + + const editorProxy1 = new FakeEditorProxy('editor-1') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy1) + + const editorProxy2 = new FakeEditorProxy('editor-2') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) + + const leftPane = atomEnv.workspace.getActivePane() + const rightPane = leftPane.splitRight({moveActiveItem: true}) + assert.equal(leftPane.getItems().length, 1) + assert.equal(rightPane.getItems().length, 1) + assert.equal(atomEnv.workspace.getActivePane(), rightPane) + + leftPane.activate() + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) + assert.equal(leftPane.getItems().length, 1) + assert.equal(rightPane.getItems().length, 1) + assert.equal(atomEnv.workspace.getActivePane(), rightPane) + }) + + test('relaying active editor changes', async () => { + const portal = new FakePortal() + const client = {joinPortal: () => portal} + const atomEnv = buildAtomEnvironment() + const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') + await portalBinding.initialize() + + // Manually switching to another editor relays active editor changes to the client. + await atomEnv.workspace.open() + assert.equal(portal.activeEditorProxyChangeCount, 1) + + portal.setFollowState(FollowState.RETRACTED) + + // Updating tether and removing editor proxies while retracted doesn't relay + // active editor changes to the client. + const editorProxy1 = new FakeEditorProxy('editor-1') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy1) + assert.equal(portal.activeEditorProxyChangeCount, 1) + + const editorProxy2 = new FakeEditorProxy('editor-2') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) + assert.equal(portal.activeEditorProxyChangeCount, 1) + + const editorProxy3 = new FakeEditorProxy('editor-3') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy3) + assert.equal(portal.activeEditorProxyChangeCount, 1) + + await portalBinding.removeEditorProxy(editorProxy3) + assert.equal(portal.activeEditorProxyChangeCount, 1) + assert(atomEnv.workspace.getActivePaneItem().getTitle().includes('editor-2')) + + portal.setFollowState(FollowState.DISCONNECTED) + + // Removing editor proxies while not retracted relays active editor changes to the client. + await portalBinding.removeEditorProxy(editorProxy2) + assert.equal(portal.activeEditorProxyChangeCount, 2) + assert(atomEnv.workspace.getActivePaneItem().getTitle().includes('editor-1')) + }) + + test('host closing last remote editor on guest workspace', async () => { + const portal = new FakePortal() + const client = {joinPortal: () => portal} + const atomEnv = buildAtomEnvironment() + const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') + + await portalBinding.initialize() + portal.setFollowState(FollowState.RETRACTED) + + const editorProxy1 = new FakeEditorProxy('editor-1') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy1) + + const editorProxy2 = new FakeEditorProxy('editor-2') + portal.setFollowState(FollowState.DISCONNECTED) + await portalBinding.updateTether(FollowState.DISCONNECTED) + + await portalBinding.removeEditorProxy(editorProxy2) + assert.equal(portal.resolveFollowState(), FollowState.DISCONNECTED) + + await portalBinding.removeEditorProxy(editorProxy1) + assert.equal(portal.getFollowedSiteId(), 1) + assert.equal(portal.resolveFollowState(), FollowState.RETRACTED) + assert(!portal.disposed) + }) + + test('toggling site position components visibility when switching tabs', async () => { + const stubPubSubGateway = {} + const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) + const portal = new FakePortal() + client.joinPortal = () => portal + const atomEnv = buildAtomEnvironment() + const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') + + await portalBinding.initialize() + assert.equal(portalBinding.sitePositionsController.visible, true) + + const localPaneItem1 = await atomEnv.workspace.open() + assert.equal(portalBinding.sitePositionsController.visible, false) + + const editorProxy = new FakeEditorProxy('some-uri') + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy) + assert.equal(portalBinding.sitePositionsController.visible, true) + + await atomEnv.workspace.open(localPaneItem1) + assert.equal(portalBinding.sitePositionsController.visible, false) + + localPaneItem1.destroy() + assert.equal(portalBinding.sitePositionsController.visible, true) + + const localPaneItem2 = await atomEnv.workspace.open() + assert.equal(portalBinding.sitePositionsController.visible, false) + + await portalBinding.removeEditorProxy(editorProxy) + localPaneItem2.destroy() + assert(atomEnv.workspace.getActivePaneItem() instanceof EmptyPortalPaneItem) + assert.equal(portalBinding.sitePositionsController.visible, true) + }) + function buildGuestPortalBinding (client, atomEnv, portalId) { return new GuestPortalBinding({ client, @@ -103,22 +217,4 @@ suite('GuestPortalBinding', () => { workspace: atomEnv.workspace }) } - - function buildEditorProxy (uri) { - const bufferProxy = { - uri, - dispose () {}, - setDelegate () {}, - createCheckpoint () {}, - groupChangesSinceCheckpoint () {}, - applyGroupingInterval () {} - } - const editorProxy = { - bufferProxy, - follow () {}, - setDelegate () {}, - updateSelections () {} - } - return editorProxy - } }) diff --git a/test/helpers/editor-helpers.js b/test/helpers/editor-helpers.js new file mode 100644 index 00000000..a5ad01f3 --- /dev/null +++ b/test/helpers/editor-helpers.js @@ -0,0 +1,22 @@ +exports.setEditorHeightInLines = async function setEditorHeightInLines (editor, lines) { + editor.element.style.height = editor.getLineHeightInPixels() * lines + 'px' + return editor.component.getNextUpdatePromise() +} + +exports.setEditorWidthInChars = async function setEditorWidthInChars (editor, chars) { + editor.element.style.width = + editor.component.getGutterContainerWidth() + + chars * editor.getDefaultCharWidth() + + 'px' + return editor.component.getNextUpdatePromise() +} + +exports.setEditorScrollTopInLines = async function setEditorScrollTopInLines (editor, lines) { + editor.element.setScrollTop(editor.getLineHeightInPixels() * lines) + return editor.component.getNextUpdatePromise() +} + +exports.setEditorScrollLeftInChars = async function setEditorScrollLeftInChars (editor, chars) { + editor.element.setScrollLeft(editor.getDefaultCharWidth() * chars) + return editor.component.getNextUpdatePromise() +} diff --git a/test/helpers/fake-editor-proxy.js b/test/helpers/fake-editor-proxy.js new file mode 100644 index 00000000..3f486b73 --- /dev/null +++ b/test/helpers/fake-editor-proxy.js @@ -0,0 +1,30 @@ +module.exports = +class FakeEditorProxy { + constructor (uri) { + this.bufferProxy = { + uri, + dispose () {}, + setDelegate () {}, + createCheckpoint () {}, + groupChangesSinceCheckpoint () {}, + applyGroupingInterval () {}, + getHistory () { + return {undoStack: [], redoStack: [], nextCheckpointId: 1} + } + } + } + + dispose () { + if (this.delegate) this.delegate.dispose() + } + + follow () {} + + didScroll () {} + + setDelegate (delegate) { + this.delegate = delegate + } + + updateSelections () {} +} diff --git a/test/helpers/fake-portal.js b/test/helpers/fake-portal.js new file mode 100644 index 00000000..d1db8ea9 --- /dev/null +++ b/test/helpers/fake-portal.js @@ -0,0 +1,66 @@ +const {FollowState} = require('@atom/teletype-client') +const FakeEditorProxy = require('./fake-editor-proxy') + +module.exports = +class FakePortal { + constructor () { + this.activeEditorProxyChangeCount = 0 + } + + dispose () {} + + createBufferProxy () { + return { + dispose () {}, + setDelegate () {}, + createCheckpoint () {}, + groupChangesSinceCheckpoint () {}, + applyGroupingInterval () {} + } + } + + createEditorProxy () { + return new FakeEditorProxy() + } + + follow (siteId) { + this.followedSiteId = siteId + this.setFollowState(FollowState.RETRACTED) + } + + unfollow () { + this.followedSiteId = null + this.setFollowState(FollowState.DISCONNECTED) + } + + setFollowState (followState) { + this.followState = followState + } + + resolveFollowState () { + return this.followState + } + + getFollowedSiteId () { + return this.followedSiteId + } + + activateEditorProxy (editorProxy) { + this.activeEditorProxy = editorProxy + this.activeEditorProxyChangeCount++ + } + + removeEditorProxy () {} + + getActiveEditorProxy () { + return this.activeEditorProxy + } + + setDelegate (delegate) { + this.delegate = delegate + } + + getSiteIdentity (siteId) { + return {login: 'site-' + siteId} + } +} diff --git a/test/helpers/ui-helpers.js b/test/helpers/ui-helpers.js new file mode 100644 index 00000000..8f1635c9 --- /dev/null +++ b/test/helpers/ui-helpers.js @@ -0,0 +1,9 @@ +const path = require('path') + +// Load package style sheets for the given environment so that the package's +// UI elements are styled correctly. +exports.loadPackageStyleSheets = function (environment) { + const packageStyleSheetPath = path.join(__dirname, '..', '..', 'styles', 'teletype.less') + const compiledStyleSheet = environment.themes.loadStylesheet(packageStyleSheetPath) + environment.styles.addStyleSheet(compiledStyleSheet) +} diff --git a/test/host-portal-binding.test.js b/test/host-portal-binding.test.js index 84af2486..2cdec45d 100644 --- a/test/host-portal-binding.test.js +++ b/test/host-portal-binding.test.js @@ -1,8 +1,10 @@ const assert = require('assert') +const {Emitter, TextEditor} = require('atom') const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') -const {TeletypeClient} = require('@atom/teletype-client') +const {FollowState, TeletypeClient} = require('@atom/teletype-client') const HostPortalBinding = require('../lib/host-portal-binding') const FakeClipboard = require('./helpers/fake-clipboard') +const FakePortal = require('./helpers/fake-portal') suite('HostPortalBinding', () => { teardown(async () => { @@ -10,8 +12,7 @@ suite('HostPortalBinding', () => { }) test('handling an unexpected error when joining a portal', async () => { - const stubPubSubGateway = {} - const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) + const client = new TeletypeClient({pubSubGateway: {}}) client.createPortal = function () { throw new Error('It broke!') } @@ -28,17 +29,8 @@ suite('HostPortalBinding', () => { }) test('showing notifications when sites join or leave', async () => { - const stubPubSubGateway = {} - const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) - const portal = { - setDelegate (delegate) { - this.delegate = delegate - }, - getSiteIdentity (siteId) { - return {login: 'site-' + siteId} - }, - setActiveEditorProxy () {} - } + const portal = new FakePortal() + const client = new TeletypeClient({pubSubGateway: {}}) client.createPortal = function () { return portal } @@ -57,6 +49,82 @@ suite('HostPortalBinding', () => { assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-3')) }) + test('switching the active editor to a remote editor that had been moved into a non-active pane', async () => { + const client = new TeletypeClient({pubSubGateway: {}}) + const portal = new FakePortal() + client.createPortal = () => portal + const atomEnv = buildAtomEnvironment() + const portalBinding = buildHostPortalBinding(client, atomEnv) + await portalBinding.initialize() + + await atomEnv.workspace.open() + await atomEnv.workspace.open() + const editorProxy2 = portal.getActiveEditorProxy() + + const leftPane = atomEnv.workspace.getActivePane() + const rightPane = leftPane.splitRight({moveActiveItem: true}) + assert.equal(leftPane.getItems().length, 1) + assert.equal(rightPane.getItems().length, 1) + assert.equal(atomEnv.workspace.getActivePane(), rightPane) + + leftPane.activate() + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) + assert.equal(leftPane.getItems().length, 1) + assert.equal(rightPane.getItems().length, 1) + assert.equal(atomEnv.workspace.getActivePane(), rightPane) + }) + + test('gracefully handles attempt to update tether for destroyed editors', async () => { + const client = new TeletypeClient({pubSubGateway: {}}) + const portal = new FakePortal() + client.createPortal = () => portal + const atomEnv = buildAtomEnvironment() + const portalBinding = buildHostPortalBinding(client, atomEnv) + await portalBinding.initialize() + + const editor = await atomEnv.workspace.open() + editor.getBuffer().setTextInRange('Lorem ipsum dolor', [[0, 0], [0, 0]], {undo: 'skip'}) + const editorProxy = portal.getActiveEditorProxy() + + await portalBinding.updateTether(FollowState.RETRACTED, editorProxy, {row: 0, column: 3}) + assert.deepEqual(editor.getCursorBufferPosition(), {row: 0, column: 3}) + + editor.destroy() + await portalBinding.updateTether(FollowState.DISCONNECTED, editorProxy, {row: 0, column: 5}) + }) + + test('toggling site position components visibility when switching between shared and non-shared pane items', async () => { + const client = new TeletypeClient({pubSubGateway: {}}) + const portal = new FakePortal() + client.createPortal = () => portal + const atomEnv = buildAtomEnvironment() + const portalBinding = buildHostPortalBinding(client, atomEnv) + + const localEditor1 = await atomEnv.workspace.open() + await portalBinding.initialize() + assert.equal(portalBinding.sitePositionsController.visible, true) + + const localNonEditor = await atomEnv.workspace.open(new FakePaneItem()) + assert.equal(portalBinding.sitePositionsController.visible, false) + + const localEditor2 = await atomEnv.workspace.open() + assert.equal(portalBinding.sitePositionsController.visible, true) + + const remoteEditor = new TextEditor() + remoteEditor.isRemote = true + await atomEnv.workspace.open(remoteEditor) + assert.equal(portalBinding.sitePositionsController.visible, false) + + await atomEnv.workspace.open(localEditor2) + assert.equal(portalBinding.sitePositionsController.visible, true) + + remoteEditor.destroy() + localEditor1.destroy() + localEditor2.destroy() + localNonEditor.destroy() + assert.equal(portalBinding.sitePositionsController.visible, false) + }) + function buildHostPortalBinding (client, atomEnv) { return new HostPortalBinding({ client, @@ -66,3 +134,18 @@ suite('HostPortalBinding', () => { }) } }) + +class FakePaneItem { + constructor () { + this.element = document.createElement('div') + this.emitter = new Emitter() + } + + destroy () { + this.emitter.emit('did-destroy') + } + + onDidDestroy (callback) { + return this.emitter.on('did-destroy', callback) + } +} diff --git a/test/portal-binding-manager.test.js b/test/portal-binding-manager.test.js index 819c59f8..489c7bc8 100644 --- a/test/portal-binding-manager.test.js +++ b/test/portal-binding-manager.test.js @@ -1,8 +1,12 @@ const assert = require('assert') -const {Disposable} = require('atom') +const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') const PortalBindingManager = require('../lib/portal-binding-manager') suite('PortalBindingManager', () => { + teardown(async () => { + await destroyAtomEnvironments() + }) + suite('host portal binding', () => { test('idempotently creating the host portal binding', async () => { const manager = buildPortalBindingManager() @@ -85,6 +89,7 @@ suite('PortalBindingManager', () => { }) function buildPortalBindingManager () { + const {workspace, notifications: notificationManager} = buildAtomEnvironment() const client = { resolveLastCreatePortalPromise: null, resolveLastJoinPortalPromise: null, @@ -95,33 +100,16 @@ function buildPortalBindingManager () { return new Promise((resolve) => { this.resolveLastJoinPortalPromise = resolve }) } } - - const notificationManager = { - addInfo () {}, - addSuccess () {}, - addError (error, options) { - throw new Error(error + '\n' + options.description) - } - } - - const workspace = { - element: document.createElement('div'), - getElement () { - return this.element - }, - observeActiveTextEditor () { - return new Disposable(() => {}) - }, - observeActivePaneItem () { - return new Disposable(() => {}) - } - } - return new PortalBindingManager({client, workspace, notificationManager}) } +let nextIdentityId = 1 function buildPortal () { return { + activateEditorProxy () {}, + getSiteIdentity () { + return {login: 'identity-' + nextIdentityId++} + }, dispose () { this.delegate.dispose() }, diff --git a/test/portal-list-component.test.js b/test/portal-list-component.test.js index a666a14c..6317bbe2 100644 --- a/test/portal-list-component.test.js +++ b/test/portal-list-component.test.js @@ -247,6 +247,16 @@ suite('PortalListComponent', function () { class FakeWorkspace { async open () {} + getCenter () { + return { + paneContainer: { + getElement () { + return document.createElement('div') + } + } + } + } + getElement () { return document.createElement('div') } @@ -254,6 +264,16 @@ class FakeWorkspace { observeActiveTextEditor () { return new Disposable(() => {}) } + + onDidDestroyPaneItem () { + return new Disposable(() => {}) + } + + onDidChangeActivePaneItem () { + return new Disposable(() => {}) + } + + paneForItem () {} } class FakeNotificationManager { diff --git a/test/site-positions-controller.test.js b/test/site-positions-controller.test.js new file mode 100644 index 00000000..8adf8a10 --- /dev/null +++ b/test/site-positions-controller.test.js @@ -0,0 +1,166 @@ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') +const {loadPackageStyleSheets} = require('./helpers/ui-helpers') +const { + setEditorHeightInLines, + setEditorWidthInChars, + setEditorScrollTopInLines, + setEditorScrollLeftInChars +} = require('./helpers/editor-helpers') +const {TextEditor, TextBuffer} = require('atom') +const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') +const FakePortal = require('./helpers/fake-portal') + +const EditorBinding = require('../lib/editor-binding') +const SitePositionsController = require('../lib/site-positions-controller') + +suite('SitePositionsController', () => { + let attachedElements = [] + + teardown(async () => { + while (attachedElements.length > 0) { + attachedElements.pop().remove() + } + + await destroyAtomEnvironments() + }) + + test('show() and hide()', async () => { + const {workspace} = buildAtomEnvironment() + const controller = new SitePositionsController({ + workspace, + portal: {}, + editorBindingForEditorProxy: () => {} + }) + + const workspaceCenterElement = workspace.getCenter().paneContainer.getElement() + + controller.show() + assert(workspaceCenterElement.contains(controller.aboveViewportSitePositionsComponent.element)) + assert(workspaceCenterElement.contains(controller.insideViewportSitePositionsComponent.element)) + assert(workspaceCenterElement.contains(controller.outsideViewportSitePositionsComponent.element)) + + controller.hide() + assert(!workspaceCenterElement.contains(controller.aboveViewportSitePositionsComponent.element)) + assert(!workspaceCenterElement.contains(controller.insideViewportSitePositionsComponent.element)) + assert(!workspaceCenterElement.contains(controller.outsideViewportSitePositionsComponent.element)) + }) + + test('updateActivePositions(positionsBySiteId)', async () => { + const environment = buildAtomEnvironment() + loadPackageStyleSheets(environment) + const {workspace} = environment + attachToDOM(workspace.getElement()) + + const portal = new FakePortal() + const controller = new SitePositionsController({workspace, portal}) + + const editorProxy1 = new FakeEditorProxy() + const editor1 = new TextEditor({autoHeight: false, buffer: new TextBuffer(SAMPLE_TEXT)}) + const editorBinding1 = new EditorBinding({portal, editor: editor1}) + editorBinding1.setEditorProxy(editorProxy1) + controller.addEditorBinding(editorBinding1) + + const editorProxy2 = new FakeEditorProxy() + const editor2 = new TextEditor({autoHeight: false, buffer: new TextBuffer(SAMPLE_TEXT)}) + const editorBinding2 = new EditorBinding({portal, editor: editor2}) + editorBinding2.setEditorProxy(editorProxy2) + controller.addEditorBinding(editorBinding2) + + const { + aboveViewportSitePositionsComponent, + insideViewportSitePositionsComponent, + outsideViewportSitePositionsComponent + } = controller + + await workspace.open(editor1) + await setEditorHeightInLines(editor1, 3) + await setEditorWidthInChars(editor1, 5) + await setEditorScrollTopInLines(editor1, 5) + await setEditorScrollLeftInChars(editor1, 5) + + const activePositionsBySiteId = { + 1: {editorProxy: editorProxy1, position: {row: 2, column: 5}}, // collaborator above visible area + 2: {editorProxy: editorProxy1, position: {row: 9, column: 5}}, // collaborator below visible area + 3: {editorProxy: editorProxy1, position: {row: 6, column: 1}}, // collaborator to the left of visible area + 4: {editorProxy: editorProxy1, position: {row: 6, column: 15}}, // collaborator to the right of visible area + 5: {editorProxy: editorProxy1, position: {row: 6, column: 6}}, // collaborator inside of visible area + 6: {editorProxy: editorProxy2, position: {row: 0, column: 0}} // collaborator in a different editor + } + controller.updateActivePositions(activePositionsBySiteId) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, [1]) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [5]) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 3, 4, 6]) + + await setEditorScrollLeftInChars(editor1, 0) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, [1]) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [3]) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 4, 5, 6]) + + await setEditorScrollTopInLines(editor1, 2) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [1, 2, 3, 4, 5, 6]) + + await setEditorHeightInLines(editor1, 7) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [3]) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [1, 2, 4, 5, 6]) + + await setEditorWidthInChars(editor1, 10) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [1, 3, 5]) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 4, 6]) + + await workspace.open(editor2) + controller.updateActivePositions(activePositionsBySiteId) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [6]) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [1, 2, 3, 4, 5]) + + await workspace.open(new TextEditor()) + controller.updateActivePositions(activePositionsBySiteId) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [1, 2, 3, 4, 5, 6]) + + await workspace.open(editor1) + controller.updateActivePositions(activePositionsBySiteId) + + assert.deepEqual(aboveViewportSitePositionsComponent.props.siteIds, []) + assert.deepEqual(insideViewportSitePositionsComponent.props.siteIds, [1, 3, 5]) + assert.deepEqual(outsideViewportSitePositionsComponent.props.siteIds, [2, 4, 6]) + + // Selecting a site will follow them. + outsideViewportSitePositionsComponent.props.onSelectSiteId(42) + assert.equal(controller.portal.getFollowedSiteId(), 42) + + // Selecting the same site again will unfollow them. + outsideViewportSitePositionsComponent.props.onSelectSiteId(42) + assert.equal(controller.portal.getFollowedSiteId(), null) + }) + + function attachToDOM (element) { + attachedElements.push(element) + document.body.insertBefore(element, document.body.firstChild) + } +}) + +class FakeEditorProxy { + constructor () { + this.bufferProxy = {uri: 'fake-buffer-proxy-uri'} + } + + didScroll () {} + + updateSelections () {} +} diff --git a/test/teletype-package.test.js b/test/teletype-package.test.js index c2aac6c3..a10f645f 100644 --- a/test/teletype-package.test.js +++ b/test/teletype-package.test.js @@ -3,6 +3,7 @@ const {Errors} = require('@atom/teletype-client') const {TextBuffer, TextEditor} = require('atom') const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') +const {loadPackageStyleSheets} = require('./helpers/ui-helpers') const assert = require('assert') const condition = require('./helpers/condition') const deepEqual = require('deep-equal') @@ -88,6 +89,7 @@ suite('TeletypePackage', function () { [[0, 5], [0, 7]] ]) await condition(() => deepEqual(getCursorDecoratedRanges(hostEditor1), getCursorDecoratedRanges(guestEditor1))) + await timeout(guestPackage.tetherDisconnectWindow) const hostEditor2 = await hostEnv.workspace.open(temp.path({extension: '.md'})) hostEditor2.setText('# Hello, World') @@ -106,6 +108,42 @@ suite('TeletypePackage', function () { assert.equal(observedGuestItems.size, 2) }) + test('opening and closing multiple editors on the host', async function () { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + const guestEnv = buildAtomEnvironment() + const guestPackage = await buildPackage(guestEnv) + const portalId = (await hostPackage.sharePortal()).id + + guestPackage.joinPortal(portalId) + const emptyPortalPaneItem = await getNextRemotePaneItemPromise(guestEnv) + assert(emptyPortalPaneItem instanceof EmptyPortalPaneItem) + + const hostEditor1 = await hostEnv.workspace.open() + const guestEditor1 = await getNextActiveTextEditorPromise(guestEnv) + assert.equal(getPaneItems(guestEnv).length, 1) + + const hostEditor2 = await hostEnv.workspace.open() + const guestEditor2 = await getNextActiveTextEditorPromise(guestEnv) // eslint-disable-line no-unused-vars + assert.equal(getPaneItems(guestEnv).length, 2) + + hostEnv.workspace.paneForItem(hostEditor1).activateItem(hostEditor1) + assert.equal(await getNextActiveTextEditorPromise(guestEnv), guestEditor1) + assert.equal(getPaneItems(guestEnv).length, 2) + + hostEditor1.destroy() + await condition(() => getPaneItems(guestEnv).length === 1) + + hostEditor2.destroy() + assert.equal(await getNextRemotePaneItemPromise(guestEnv), emptyPortalPaneItem) + assert.equal(getPaneItems(guestEnv).length, 1) + + await hostEnv.workspace.open() + const guestEditor3 = await getNextRemotePaneItemPromise(guestEnv) + assert(guestEditor3 instanceof TextEditor) + assert.equal(getPaneItems(guestEnv).length, 1) + }) + test('host joining another portal as a guest', async () => { const hostAndGuestEnv = buildAtomEnvironment() const hostAndGuestPackage = await buildPackage(hostAndGuestEnv) @@ -131,10 +169,7 @@ suite('TeletypePackage', function () { // No transitivity: When Portal 1 host is viewing contents of Portal 2, Portal 1 guests are placed on hold assert.equal(hostAndGuestEnv.workspace.getActivePaneItem(), hostAndGuestRemotePaneItem) - await condition(() => - getRemotePaneItems(guestOnlyEnv).length === 1 && - getRemotePaneItems(guestOnlyEnv)[0] instanceof EmptyPortalPaneItem - ) + await condition(() => deepEqual(getPaneItems(guestOnlyEnv), [guestOnlyRemotePaneItem1])) }) test('guest sharing another portal as a host', async () => { @@ -147,42 +182,43 @@ suite('TeletypePackage', function () { // Start out as a guest in another user's portal (Portal 1) const portal1Id = (await hostOnlyPackage.sharePortal()).id - guestAndHostPackage.joinPortal(portal1Id) + const guestAndHostPortal1 = await guestAndHostPackage.joinPortal(portal1Id) hostOnlyEnv.workspace.open(path.join(temp.path(), 'host-only-buffer-1')) const guestAndHostRemotePaneItem1 = await getNextRemotePaneItemPromise(guestAndHostEnv) assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem1]) // While already participating as a guest in Portal 1, share a new portal as a host (Portal 2) - const guestAndHostLocalEditor = await guestAndHostEnv.workspace.open(path.join(temp.path(), 'host+guest')) - assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem1, guestAndHostLocalEditor]) + const guestAndHostLocalEditor1 = await guestAndHostEnv.workspace.open(path.join(temp.path(), 'host+guest-buffer-1')) + assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem1, guestAndHostLocalEditor1]) const portal2Id = (await guestAndHostPackage.sharePortal()).id guestOnlyPackage.joinPortal(portal2Id) const guestOnlyRemotePaneItem1 = await getNextRemotePaneItemPromise(guestOnlyEnv) assert(guestOnlyRemotePaneItem1 instanceof TextEditor) assert.deepEqual(getPaneItems(guestOnlyEnv), [guestOnlyRemotePaneItem1]) + guestAndHostPortal1.follow(1) // reconnect tether after disconnecting it due to opening a local editor. // Portal 2 host continues to exist as a guest in Portal 1 hostOnlyEnv.workspace.open(path.join(temp.path(), 'host-only-buffer-2')) const guestAndHostRemotePaneItem2 = await getNextRemotePaneItemPromise(guestAndHostEnv) - assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem2, guestAndHostLocalEditor]) + assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem1, guestAndHostRemotePaneItem2, guestAndHostLocalEditor1]) assert.deepEqual(getPaneItems(guestOnlyEnv), [guestOnlyRemotePaneItem1]) - // No transitivity: When Portal 2 host is viewing contents of Portal 1, Portal 2 guests are placed on hold + // No transitivity: When Portal 2 host is viewing contents of Portal 1, Portal 2 guests can only see contents of Portal 2 guestAndHostEnv.workspace.getActivePane().activateItemAtIndex(0) - assert.equal(guestAndHostEnv.workspace.getActivePaneItem(), guestAndHostRemotePaneItem2) - await condition(() => - getRemotePaneItems(guestOnlyEnv).length === 1 && - getRemotePaneItems(guestOnlyEnv)[0] instanceof EmptyPortalPaneItem - ) + assert.equal(guestAndHostEnv.workspace.getActivePaneItem(), guestAndHostRemotePaneItem1) + assert.deepEqual(getPaneItems(guestOnlyEnv), [guestOnlyRemotePaneItem1]) + guestAndHostPortal1.follow(1) // reconnect tether after disconnecting it due to switching to a different editor. - // Portal 2 guests remain on hold while Portal 2 host observes changes in Portal 1 + // As Portal 2 host observes changes in Portal 1, Portal 2 guests continue to only see contents of Portal 2 await hostOnlyEnv.workspace.open(path.join(temp.path(), 'host-only-buffer-3')) const guestAndHostRemotePaneItem3 = await getNextRemotePaneItemPromise(guestAndHostEnv) - assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem3, guestAndHostLocalEditor]) - await condition(() => - getRemotePaneItems(guestOnlyEnv).length === 1 && - getRemotePaneItems(guestOnlyEnv)[0] instanceof EmptyPortalPaneItem - ) + assert.deepEqual(getPaneItems(guestAndHostEnv), [guestAndHostRemotePaneItem1, guestAndHostRemotePaneItem2, guestAndHostRemotePaneItem3, guestAndHostLocalEditor1]) + assert.deepEqual(getPaneItems(guestOnlyEnv), [guestOnlyRemotePaneItem1]) + + // When Portal 2 host shares another local buffer, Portal 2 guests see that buffer + await guestAndHostEnv.workspace.open(path.join(temp.path(), 'host+guest-buffer-2')) + const guestOnlyRemotePaneItem2 = await getNextRemotePaneItemPromise(guestOnlyEnv) + assert.deepEqual(getPaneItems(guestOnlyEnv), [guestOnlyRemotePaneItem1, guestOnlyRemotePaneItem2]) }) test('host attempting to share another portal', async () => { @@ -336,9 +372,9 @@ suite('TeletypePackage', function () { assert(isTransmitting(guestStatusBar)) assert.equal(guestEnv.workspace.getPaneItems().length, 2) - guestEnv.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + await guestPackage.leavePortal() assert(isTransmitting(guestStatusBar)) - guestEnv.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + await guestPackage.leavePortal() await condition(() => !isTransmitting(guestStatusBar)) await host1Package.closeHostPortal() @@ -355,54 +391,25 @@ suite('TeletypePackage', function () { assert(errorNotification, 'Expected notifications to include "Portal not found" error') }) - test('preserving guest portal position in workspace', async function () { - const hostEnv = buildAtomEnvironment() - const hostPackage = await buildPackage(hostEnv) - const guestEnv = buildAtomEnvironment() - const guestPackage = await buildPackage(guestEnv) - - const guestLocalEditor1 = await guestEnv.workspace.open(path.join(temp.path(), 'guest-1')) - assert.deepEqual(getPaneItems(guestEnv), [guestLocalEditor1]) - - const portal = await hostPackage.sharePortal() - await guestPackage.joinPortal(portal.id) - await hostEnv.workspace.open(path.join(temp.path(), 'host-1')) - const guestRemoteEditor1 = await getNextRemotePaneItemPromise(guestEnv) - const guestLocalEditor2 = await guestEnv.workspace.open(path.join(temp.path(), 'guest-2')) - assert.deepEqual(getPaneItems(guestEnv), [guestLocalEditor1, guestRemoteEditor1, guestLocalEditor2]) - - await hostEnv.workspace.open(path.join(temp.path(), 'host-2')) - const guestRemoteEditor2 = await getNextRemotePaneItemPromise(guestEnv) - - assert.deepEqual(getPaneItems(guestEnv), [guestLocalEditor1, guestRemoteEditor2, guestLocalEditor2]) - }) - - test('host without an active text editor', async function () { - const hostEnv = buildAtomEnvironment() - const hostPackage = await buildPackage(hostEnv) - const guestEnv = buildAtomEnvironment() - const guestPackage = await buildPackage(guestEnv) - const portalId = (await hostPackage.sharePortal()).id - - guestPackage.joinPortal(portalId) - let guestEditor = await getNextRemotePaneItemPromise(guestEnv) - assert(guestEditor instanceof EmptyPortalPaneItem) - - const hostEditor1 = await hostEnv.workspace.open() - guestEditor = await getNextRemotePaneItemPromise(guestEnv) - assert(guestEditor instanceof TextEditor) + suite('guest leaving portal', () => { + test('via explicit leave action', async () => { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + const guestEnv = buildAtomEnvironment() + const guestPackage = await buildPackage(guestEnv) + const portal = await hostPackage.sharePortal() + await guestPackage.joinPortal(portal.id) - hostEditor1.destroy() - guestEditor = await getNextRemotePaneItemPromise(guestEnv) - assert(guestEditor instanceof EmptyPortalPaneItem) + await hostEnv.workspace.open() + await hostEnv.workspace.open() + await hostEnv.workspace.open() + await condition(() => getPaneItems(guestEnv).length === 3) - await hostEnv.workspace.open() - guestEditor = await getNextRemotePaneItemPromise(guestEnv) - assert(guestEditor instanceof TextEditor) - }) + await guestPackage.leavePortal() + await condition(() => getPaneItems(guestEnv).length === 0) + }) - suite('guest leaving portal', async () => { - test('via closing text editor portal pane item', async () => { + test('via closing last remote editor', async () => { const hostEnv = buildAtomEnvironment() const hostPackage = await buildPackage(hostEnv) const hostPortal = await hostPackage.sharePortal() @@ -412,7 +419,8 @@ suite('TeletypePackage', function () { const guestPackage = await buildPackage(guestEnv) const guestPortal = await guestPackage.joinPortal(hostPortal.id) - const guestEditor = getRemotePaneItems(guestEnv)[0] + await condition(() => getRemotePaneItems(guestEnv).length === 1) + const guestEditor = guestEnv.workspace.getActivePaneItem() assert(guestEditor instanceof TextEditor) guestEnv.workspace.closeActivePaneItemOrEmptyPaneOrWindow() assert(guestPortal.disposed) @@ -421,10 +429,9 @@ suite('TeletypePackage', function () { test('via closing empty portal pane item', async () => { const hostEnv = buildAtomEnvironment() const hostPackage = await buildPackage(hostEnv) - const hostPortal = await hostPackage.sharePortal() - const guestEnv = buildAtomEnvironment() const guestPackage = await buildPackage(guestEnv) + const hostPortal = await hostPackage.sharePortal() const guestPortal = await guestPackage.joinPortal(hostPortal.id) const guestEditor = getRemotePaneItems(guestEnv)[0] @@ -471,34 +478,43 @@ suite('TeletypePackage', function () { const hostEditor1 = await hostEnv.workspace.open(path.join(temp.path(), 'file-1')) hostEditor1.setText('const hello = "world"') hostEditor1.setCursorBufferPosition([0, 4]) - await getNextActiveTextEditorPromise(guestEnv) + const guestEditor1 = await getNextActiveTextEditorPromise(guestEnv) const hostEditor2 = await hostEnv.workspace.open(path.join(temp.path(), 'file-2')) hostEditor2.setText('const goodnight = "moon"') hostEditor2.setCursorBufferPosition([0, 2]) - await condition(() => guestEnv.workspace.getActiveTextEditor().getText() === 'const goodnight = "moon"') + const guestEditor2 = await getNextActiveTextEditorPromise(guestEnv) - const guestEditor = guestEnv.workspace.getActiveTextEditor() - await condition(() => deepEqual(getCursorDecoratedRanges(hostEditor2), getCursorDecoratedRanges(guestEditor))) - guestEditor.setCursorBufferPosition([0, 5]) + await condition(() => deepEqual(getCursorDecoratedRanges(hostEditor2), getCursorDecoratedRanges(guestEditor2))) + guestEditor2.setCursorBufferPosition([0, 5]) - const guestEditorTitleChangeEvents = [] - guestEditor.onDidChangeTitle((title) => guestEditorTitleChangeEvents.push(title)) + const guestEditor1TitleChangeEvents = [] + const guestEditor2TitleChangeEvents = [] + guestEditor1.onDidChangeTitle((title) => guestEditor1TitleChangeEvents.push(title)) + guestEditor2.onDidChangeTitle((title) => guestEditor2TitleChangeEvents.push(title)) hostPackage.closeHostPortal() - await condition(() => guestEditor.getTitle() === 'untitled') - assert.deepEqual(guestEditorTitleChangeEvents, ['untitled']) - assert.equal(guestEditor.getText(), 'const goodnight = "moon"') - assert(guestEditor.isModified()) - assert.deepEqual(getCursorDecoratedRanges(guestEditor), [ + await condition(() => guestEditor1.getTitle() === 'untitled' && guestEditor2.getTitle() === 'untitled') + + assert.deepEqual(guestEditor1TitleChangeEvents, ['untitled']) + assert.equal(guestEditor1.getText(), 'const hello = "world"') + assert(guestEditor1.isModified()) + assert.deepEqual(getCursorDecoratedRanges(guestEditor1), [ + {start: {row: 0, column: 4}, end: {row: 0, column: 4}} + ]) + + assert.deepEqual(guestEditor2TitleChangeEvents, ['untitled']) + assert.equal(guestEditor2.getText(), 'const goodnight = "moon"') + assert(guestEditor2.isModified()) + assert.deepEqual(getCursorDecoratedRanges(guestEditor2), [ {start: {row: 0, column: 5}, end: {row: 0, column: 5}} ]) // Ensure that the guest can still edit the buffer or modify selections. - guestEditor.getBuffer().setTextInRange([[0, 0], [0, 5]], 'let') - guestEditor.setCursorBufferPosition([0, 7]) - assert.equal(guestEditor.getText(), 'let goodnight = "moon"') - assert.deepEqual(getCursorDecoratedRanges(guestEditor), [ + guestEditor2.getBuffer().setTextInRange([[0, 0], [0, 5]], 'let') + guestEditor2.setCursorBufferPosition([0, 7]) + assert.equal(guestEditor2.getText(), 'let goodnight = "moon"') + assert.deepEqual(getCursorDecoratedRanges(guestEditor2), [ {start: {row: 0, column: 7}, end: {row: 0, column: 7}} ]) }) @@ -695,45 +711,88 @@ suite('TeletypePackage', function () { } }) - test('splitting editors', async () => { - const hostEnv = buildAtomEnvironment() - const hostPackage = await buildPackage(hostEnv) - const portal = await hostPackage.sharePortal() + suite('host splitting editors', async () => { + test('supporting distinct selections per editor with a shared undo stack for the buffer', async () => { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + const portal = await hostPackage.sharePortal() - const guestEnv = buildAtomEnvironment() - const guestPackage = await buildPackage(guestEnv) - guestPackage.joinPortal(portal.id) + const guestEnv = buildAtomEnvironment() + const guestPackage = await buildPackage(guestEnv) + guestPackage.joinPortal(portal.id) + + const hostEditor1 = await hostEnv.workspace.open() + hostEditor1.setText('hello = "world"') + hostEditor1.setCursorBufferPosition([0, 0]) + hostEditor1.insertText('const ') + + hostEnv.workspace.paneForItem(hostEditor1).splitRight({copyActiveItem: true}) + const hostEditor2 = hostEnv.workspace.getActiveTextEditor() + hostEditor2.setCursorBufferPosition([0, 8]) + + assert.equal(hostEditor2.getBuffer(), hostEditor1.getBuffer()) + + const guestEditor2 = await getNextActiveTextEditorPromise(guestEnv) + guestEditor2.setCursorBufferPosition([0, Infinity]) + guestEditor2.insertText('\nconst goodbye = "moon"') + await editorsEqual(guestEditor2, hostEditor2) + await timeout(guestPackage.tetherDisconnectWindow) + + hostEditor2.undo() + assert.equal(hostEditor2.getText(), 'hello = "world"\nconst goodbye = "moon"') + assert.equal(hostEditor1.getText(), hostEditor2.getText()) + await editorsEqual(hostEditor2, guestEditor2) + + hostEnv.workspace.paneForItem(hostEditor1).activate() + const guestEditor1 = await getNextActiveTextEditorPromise(guestEnv) + assert.equal(guestEditor1.getBuffer(), guestEditor2.getBuffer()) + await editorsEqual(guestEditor1, hostEditor1) + + guestEditor1.undo() + assert.equal(guestEditor1.getText(), 'hello = "world"') + assert.equal(guestEditor2.getText(), guestEditor1.getText()) + await editorsEqual(guestEditor1, hostEditor1) + }) - const hostEditor1 = await hostEnv.workspace.open() - hostEditor1.setText('hello = "world"') - hostEditor1.setCursorBufferPosition([0, 0]) - hostEditor1.insertText('const ') + test('remotifying and deremotifying guest editors and buffers', async () => { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + const portal = await hostPackage.sharePortal() - hostEnv.workspace.paneForItem(hostEditor1).splitRight({copyActiveItem: true}) - const hostEditor2 = hostEnv.workspace.getActiveTextEditor() - hostEditor2.setCursorBufferPosition([0, 8]) + const guestEnv = buildAtomEnvironment() + const guestPackage = await buildPackage(guestEnv) + guestPackage.joinPortal(portal.id) - assert.equal(hostEditor2.getBuffer(), hostEditor1.getBuffer()) + const hostEditor1 = await hostEnv.workspace.open(path.join(temp.path(), 'a.txt')) + const guestEditor1 = await getNextActiveTextEditorPromise(guestEnv) - const guestEditor2 = await getNextActiveTextEditorPromise(guestEnv) - guestEditor2.setCursorBufferPosition([0, Infinity]) - guestEditor2.insertText('\nconst goodbye = "moon"') - await editorsEqual(guestEditor2, hostEditor2) + hostEnv.workspace.paneForItem(hostEditor1).splitRight({copyActiveItem: true}) + const hostEditor2 = hostEnv.workspace.getActiveTextEditor() + const guestEditor2 = await getNextActiveTextEditorPromise(guestEnv) - hostEditor2.undo() - assert.equal(hostEditor2.getText(), 'hello = "world"\nconst goodbye = "moon"') - assert.equal(hostEditor1.getText(), hostEditor2.getText()) - await editorsEqual(hostEditor2, guestEditor2) + assert.deepEqual(getPaneItems(guestEnv), [guestEditor1, guestEditor2]) + assert(guestEditor1.isRemote) + assert(guestEditor1.getTitle().endsWith('a.txt')) + assert(guestEditor1.getBuffer().getPath().endsWith('a.txt')) + assert(guestEditor2.isRemote) + assert(guestEditor2.getTitle().endsWith('a.txt')) + assert(guestEditor2.getBuffer().getPath().endsWith('a.txt')) - hostEnv.workspace.paneForItem(hostEditor1).activate() - const guestEditor1 = await getNextActiveTextEditorPromise(guestEnv) - assert.equal(guestEditor1.getBuffer(), guestEditor2.getBuffer()) - await editorsEqual(guestEditor1, hostEditor1) + hostEditor2.destroy() + await condition(() => deepEqual(getPaneItems(guestEnv), [guestEditor1])) + + assert(guestEditor1.isRemote) + assert(guestEditor1.getTitle().endsWith('a.txt')) + assert(guestEditor1.getBuffer().getPath().endsWith('a.txt')) + + hostPackage.closeHostPortal() - guestEditor1.undo() - assert.equal(guestEditor1.getText(), 'hello = "world"') - assert.equal(guestEditor2.getText(), guestEditor1.getText()) - await editorsEqual(guestEditor1, hostEditor1) + await condition(() => + !guestEditor1.isRemote && + guestEditor1.getTitle() === 'untitled' && + guestEditor1.getBuffer().getPath() === undefined + ) + }) }) test('propagating nested marker layer updates that depend on text updates in a nested transaction', async () => { @@ -757,92 +816,192 @@ suite('TeletypePackage', function () { await condition(() => deepEqual(getCursorDecoratedRanges(hostEditor), getCursorDecoratedRanges(guestEditor))) }) - test('tethering to other collaborators', async () => { - const hostEnv = buildAtomEnvironment() - const hostPackage = await buildPackage(hostEnv) - const guestEnv = buildAtomEnvironment() - const guestPackage = await buildPackage(guestEnv) - const guestWorkspaceElement = guestEnv.views.getView(guestEnv.workspace) - guestWorkspaceElement.style.height = '100px' - guestWorkspaceElement.style.width = '250px' - containerElement.appendChild(guestWorkspaceElement) + suite('tethering', () => { + test('guest following host', async () => { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + const guestEnv = buildAtomEnvironment() + const guestPackage = await buildPackage(guestEnv) - const hostEditor1 = await hostEnv.workspace.open() - hostEditor1.setText(('x'.repeat(30) + '\n').repeat(30)) - hostEditor1.setCursorBufferPosition([2, 2]) + const hostPortal = await hostPackage.sharePortal() + const guestPortal = await guestPackage.joinPortal(hostPortal.id) - const portal = await hostPackage.sharePortal() - guestPackage.joinPortal(portal.id) + const hostEditor1 = await hostEnv.workspace.open() + hostEditor1.setText(('x'.repeat(30) + '\n').repeat(30)) + hostEditor1.setCursorBufferPosition([2, 2]) - const guestEditor1 = await getNextActiveTextEditorPromise(guestEnv) + const hostEditor2 = await hostEnv.workspace.open() + hostEditor2.setText(('y'.repeat(30) + '\n').repeat(30)) + hostEditor2.setCursorBufferPosition([2, 2]) - // Jump to host cursor when joining - await condition(() => deepEqual(guestEditor1.getCursorBufferPosition(), hostEditor1.getCursorBufferPosition())) - - // Initially, guests follow the host's cursor - hostEditor1.setCursorBufferPosition([3, 3]) - await condition(() => deepEqual(guestEditor1.getCursorBufferPosition(), hostEditor1.getCursorBufferPosition())) - - // When followers move their cursor, their cursor does not follow the - // leader's cursor so long as the leader's cursor stays within the - // follower's viewport - guestEditor1.setCursorBufferPosition([2, 10]) - hostEditor1.setCursorBufferPosition([3, 5]) - hostEditor1.insertText('y') - await condition(() => guestEditor1.lineTextForBufferRow(3).includes('y')) - assert(guestEditor1.getCursorBufferPosition().isEqual([2, 10])) - - // When the leader moves their cursor out of the follower's viewport, the - // follower's cursor moves to the same position if the unfollow period - // has elapsed. - await timeout(guestPackage.tetherDisconnectWindow) - hostEditor1.setCursorBufferPosition([20, 10]) - await condition(() => deepEqual(guestEditor1.getCursorBufferPosition(), hostEditor1.getCursorBufferPosition())) + await condition(() => guestEnv.workspace.getTextEditors().length === 2) - // If the leader moves to non-visible columns (not just rows), we update - // the tether - await condition(() => guestEditor1.getFirstVisibleScreenRow() > 0) - guestEditor1.setCursorBufferPosition([20, 9]) - await timeout(guestPackage.tetherDisconnectWindow) - hostEditor1.setCursorBufferPosition([20, 30]) - await condition(() => deepEqual(guestEditor1.getCursorBufferPosition(), hostEditor1.getCursorBufferPosition())) - - // Disconnect tether if leader's cursor position moves within the tether - // disconnect window - guestEditor1.setCursorBufferPosition([20, 29]) - hostEditor1.setCursorBufferPosition([0, 0]) - hostEditor1.insertText('y') - await condition(() => guestEditor1.lineTextForBufferRow(0).includes('y')) - assert(guestEditor1.getCursorBufferPosition().isEqual([20, 29])) - await timeout(guestPackage.tetherDisconnectWindow) - hostEditor1.setCursorBufferPosition([1, 0]) - hostEditor1.insertText('y') - await condition(() => guestEditor1.lineTextForBufferRow(1).includes('y')) - assert(guestEditor1.getCursorBufferPosition().isEqual([20, 29])) + await verifyTetheringRules({ + leaderEnv: hostEnv, + leaderPortal: hostPortal, + followerEnv: guestEnv, + followerPortal: guestPortal + }) + }) - // Reconnect and retract the tether when the host switches editors - const hostEditor2 = await hostEnv.workspace.open() - hostEditor2.setText(('y'.repeat(30) + '\n').repeat(30)) - hostEditor2.setCursorBufferPosition([2, 2]) - const guestEditor2 = await getNextActiveTextEditorPromise(guestEnv) - await condition(() => deepEqual(guestEditor2.getCursorBufferPosition(), hostEditor2.getCursorBufferPosition())) - hostEditor2.setCursorBufferPosition([4, 4]) - await condition(() => deepEqual(guestEditor2.getCursorBufferPosition(), hostEditor2.getCursorBufferPosition())) + test('host following guest', async () => { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + const guestEnv = buildAtomEnvironment() + const guestPackage = await buildPackage(guestEnv) - // Disconnect tether if guest scrolls the tether position out of view - guestEditor2.setCursorBufferPosition([20, 0]) - await timeout(guestPackage.tetherDisconnectWindow) - hostEditor2.setCursorBufferPosition([4, 5]) - hostEditor2.insertText('z') - await condition(() => guestEditor2.lineTextForBufferRow(4).includes('z')) - assert(guestEditor2.getCursorBufferPosition().isEqual([20, 0])) - - // When host switches back to an existing editor, reconnect the tether - hostEnv.workspace.getActivePane().activateItem(hostEditor1) - await getNextActiveTextEditorPromise(guestEnv) - await condition(() => deepEqual(guestEditor1.getCursorBufferPosition(), hostEditor1.getCursorBufferPosition())) - hostEditor1.setCursorBufferPosition([1, 20]) - await condition(() => deepEqual(guestEditor1.getCursorBufferPosition(), hostEditor1.getCursorBufferPosition())) + const hostPortal = await hostPackage.sharePortal() + const guestPortal = await guestPackage.joinPortal(hostPortal.id) + + const hostEditor1 = await hostEnv.workspace.open() + hostEditor1.setText(('x'.repeat(30) + '\n').repeat(30)) + hostEditor1.setCursorBufferPosition([2, 2]) + + const hostEditor2 = await hostEnv.workspace.open() + hostEditor2.setText(('y'.repeat(30) + '\n').repeat(30)) + hostEditor2.setCursorBufferPosition([2, 2]) + + await condition(() => guestEnv.workspace.getTextEditors().length === 2) + + await verifyTetheringRules({ + leaderEnv: guestEnv, + leaderPortal: guestPortal, + followerEnv: hostEnv, + followerPortal: hostPortal + }) + }) + + test('guest following guest', async () => { + const hostEnv = buildAtomEnvironment() + const hostPackage = await buildPackage(hostEnv) + + const guest1Env = buildAtomEnvironment() + const guest1Package = await buildPackage(guest1Env) + + const guest2Env = buildAtomEnvironment() + const guest2Package = await buildPackage(guest2Env) + + const hostPortal = await hostPackage.sharePortal() + const guest1Portal = await guest1Package.joinPortal(hostPortal.id) + const guest2Portal = await guest2Package.joinPortal(hostPortal.id) + + const hostEditor1 = await hostEnv.workspace.open() + hostEditor1.setText(('x'.repeat(30) + '\n').repeat(30)) + hostEditor1.setCursorBufferPosition([2, 2]) + + const hostEditor2 = await hostEnv.workspace.open() + hostEditor2.setText(('y'.repeat(30) + '\n').repeat(30)) + hostEditor2.setCursorBufferPosition([2, 2]) + + await condition(() => guest1Env.workspace.getTextEditors().length === 2) + await condition(() => guest2Env.workspace.getTextEditors().length === 2) + + await verifyTetheringRules({ + leaderEnv: guest1Env, + leaderPortal: guest1Portal, + followerEnv: guest2Env, + followerPortal: guest2Portal + }) + }) + + async function verifyTetheringRules ({leaderEnv, leaderPortal, followerEnv, followerPortal}) { + // Setup DOM for follower's workspace. + loadPackageStyleSheets(followerEnv) + const followerWorkspaceElement = followerEnv.views.getView(followerEnv.workspace) + followerWorkspaceElement.style.height = '100px' + followerWorkspaceElement.style.width = '250px' + containerElement.appendChild(followerWorkspaceElement) + + const leaderEditors = leaderEnv.workspace.getTextEditors() + const followerEditors = followerEnv.workspace.getTextEditors() + + // Reset follow state. + leaderPortal.unfollow() + followerPortal.unfollow() + + // Jump to leader cursor and follow it as it moves. + leaderEnv.workspace.getActivePane().activateItem(leaderEditors[0]) + followerPortal.follow(leaderPortal.siteId) + await condition(() => ( + followerEnv.workspace.getActivePaneItem() === followerEditors[0] && + deepEqual(followerEditors[0].getCursorBufferPosition(), leaderEditors[0].getCursorBufferPosition()) + )) + + leaderEditors[0].setCursorBufferPosition([3, 3]) + await condition(() => deepEqual(followerEditors[0].getCursorBufferPosition(), leaderEditors[0].getCursorBufferPosition())) + + // When followers move their cursor, their cursor does not follow the + // leader's cursor so long as the leader's cursor stays within the + // follower's viewport. + followerEditors[0].setCursorBufferPosition([2, 10]) + leaderEditors[0].setCursorBufferPosition([3, 5]) + leaderEditors[0].insertText('Y') + await condition(() => followerEditors[0].lineTextForBufferRow(3).includes('Y')) + assert(followerEditors[0].getCursorBufferPosition().isEqual([2, 10])) + + // When the leader moves their cursor out of the follower's viewport, the + // follower's cursor moves to the same position if the unfollow period + // has elapsed. + await timeout(followerPortal.tetherDisconnectWindow) + leaderEditors[0].setCursorBufferPosition([20, 10]) + await condition(() => deepEqual(followerEditors[0].getCursorBufferPosition(), leaderEditors[0].getCursorBufferPosition())) + + // If the leader moves to non-visible columns (not just rows), we update + // the tether. + await condition(() => followerEditors[0].getFirstVisibleScreenRow() > 0) + followerEditors[0].setCursorBufferPosition([20, 9]) + await timeout(followerPortal.tetherDisconnectWindow) + leaderEditors[0].setCursorBufferPosition([20, 30]) + await condition(() => deepEqual(followerEditors[0].getCursorBufferPosition(), leaderEditors[0].getCursorBufferPosition())) + + // Disconnect tether if leader's cursor position moves within the tether + // disconnect window. + followerEditors[0].setCursorBufferPosition([20, 29]) + leaderEditors[0].setCursorBufferPosition([0, 0]) + leaderEditors[0].insertText('Y') + await condition(() => followerEditors[0].lineTextForBufferRow(0).includes('Y')) + assert(followerEditors[0].getCursorBufferPosition().isEqual([20, 29])) + await timeout(followerPortal.tetherDisconnectWindow) + leaderEditors[0].setCursorBufferPosition([1, 0]) + leaderEditors[0].insertText('Y') + await condition(() => followerEditors[0].lineTextForBufferRow(1).includes('Y')) + assert(followerEditors[0].getCursorBufferPosition().isEqual([20, 29])) + + // When re-following, ensure that you are taken to the leader's current tab. + leaderEnv.workspace.paneForItem(leaderEditors[1]).activateItem(leaderEditors[1]) + followerPortal.follow(leaderPortal.siteId) + + await condition(() => deepEqual(followerEditors[1].getCursorBufferPosition(), leaderEditors[1].getCursorBufferPosition())) + leaderEditors[1].setCursorBufferPosition([4, 4]) + await condition(() => deepEqual(followerEditors[1].getCursorBufferPosition(), leaderEditors[1].getCursorBufferPosition())) + + // Disconnect tether if follower scrolls the tether position out of view. + followerEditors[1].setCursorBufferPosition([20, 0]) + await timeout(followerPortal.tetherDisconnectWindow) + leaderEditors[1].setCursorBufferPosition([4, 5]) + leaderEditors[1].insertText('Z') + await condition(() => followerEditors[1].lineTextForBufferRow(4).includes('Z')) + assert(followerEditors[1].getCursorBufferPosition().isEqual([20, 0])) + + // Retract follower's tether and ensure it gets disconnected after switching to a different tab. + followerPortal.follow(leaderPortal.siteId) + await condition(() => deepEqual(followerEditors[1].getCursorBufferPosition(), leaderEditors[1].getCursorBufferPosition())) + + followerEnv.workspace.getActivePane().activateItem(followerEditors[0]) + await timeout(followerPortal.tetherDisconnectWindow) + + followerEditors[0].setCursorBufferPosition([3, 4]) + leaderEditors[1].setCursorBufferPosition([8, 2]) + leaderEditors[1].insertText('X') + + await condition(() => followerEditors[1].lineTextForBufferRow(8).includes('X')) + + assert.equal(followerEnv.workspace.getActivePaneItem(), followerEditors[0]) + assert(getCursorDecoratedRanges(followerEditors[0]).find((r) => r.isEqual([[3, 4], [3, 4]]))) + + assert.equal(leaderEnv.workspace.getActivePaneItem(), leaderEditors[1]) + assert(getCursorDecoratedRanges(leaderEditors[1]).find((r) => r.isEqual([[8, 3], [8, 3]]))) + } }) test('adding and removing workspace element classes when sharing a portal', async () => {