├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── doc └── rfcs │ ├── 000-template.md │ ├── 001-allow-guests-to-open-multiple-remote-buffers.md │ ├── 002-sync-buffer-path-changes-from-host-to-guest.md │ ├── 003-share-and-join-a-portal-via-url.md │ └── 004-quickly-collaborate-with-coworkers-and-friends.md ├── index.js ├── lib ├── authentication-provider.js ├── buffer-binding.js ├── credential-cache.js ├── editor-binding.js ├── get-avatar-url.js ├── get-path-with-native-separators.js ├── guest-portal-binding-component.js ├── guest-portal-binding.js ├── host-portal-binding-component.js ├── host-portal-binding.js ├── join-portal-component.js ├── join-via-external-app-dialog.js ├── package-initialization-error-component.js ├── package-outdated-component.js ├── participants-component.js ├── popover-component.js ├── portal-binding-manager.js ├── portal-id-helpers.js ├── portal-list-component.js ├── portal-status-bar-indicator.js ├── sign-in-component.js ├── site-positions-component.js ├── teletype-package.js ├── teletype-service.js └── uri-helpers.js ├── menus └── teletype.json ├── package-lock.json ├── package.json ├── styles └── teletype.less └── test ├── buffer-binding.test.js ├── credential-cache.test.js ├── editor-binding.test.js ├── fixtures └── sample.js ├── get-path-with-native-separators.test.js ├── guest-portal-binding.test.js ├── helpers ├── atom-environments.js ├── condition.js ├── editor-helpers.js ├── fake-authentication-provider.js ├── fake-buffer-proxy.js ├── fake-clipboard.js ├── fake-command-registry.js ├── fake-credential-cache.js ├── fake-editor-proxy.js ├── fake-notification-manager.js ├── fake-portal.js ├── fake-status-bar.js ├── fake-workspace.js └── ui-helpers.js ├── host-portal-binding.test.js ├── portal-binding-manager.test.js ├── portal-list-component.test.js ├── setup.js ├── sign-in-component.test.js ├── site-positions-component.test.js ├── teletype-package.test.js └── teletype-service.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '7' 3 | sudo: false 4 | os: linux 5 | dist: xenial 6 | 7 | env: 8 | matrix: 9 | - ATOM_CHANNEL=stable 10 | - ATOM_CHANNEL=beta 11 | - ATOM_CHANNEL=dev 12 | 13 | notifications: 14 | email: 15 | on_success: never 16 | on_failure: change 17 | slack: 18 | on_success: never 19 | on_failure: never 20 | rooms: 21 | - secure: om1VFZYOtLRBOHh3+UDms24wIoRsBu73jMrvNoI2/ThElkihHnD6LbdI1qkPEOkufgMlSjTzQml0VqJgN4gACHfB3Y/cx2k0ThUs7H8Zjv7h90xeegMrj5yA+m2ahqln6rZxtSfog/3owYi9m75iZlXKbl72oXBhCrQMcZ4/BIktdLhR8loEZrYoFgxTOxt6kQwHu67WGmtaRdZcp11ve8ToWqp/Wm1IWGRjeNe5C3dHevS4xsUTRK+hoIov1/nwYysQ8RgmxgJGwzwtCjNkwyqwWku9M0ACVqdqXlFYmcNNWWj2e9buVP9mkX9KHVhPaA72CtgPgO1cvV6HFeA4npn/UKHi+FsMfeGBUkUYP+sQ/CauiSq0LW2zoQIlzsFr5GNI5l2kMhQ9cKoA0CMPwfAjK2rRLLx9c61vNjFqVJtL3KiaYsgPnss8CWprvPgCUjWwbPknjY899EVxhP0bcSt1Nyh0XkzFSCFTWGMWwz/u31w3CVOWE0ez1OdjW4is7EmKhH08Zkt46e/Rr5qZFobc9RM1JYhW67rFPvged4eCz0opxrjci2RcYMh/vV+JJF3NYcpxkEI3dRLB1xpQDL0PtEsuvTSIjCRZYcc4RYb+4NDp7vIgMf20Gt+kTwYs30KyCVMTNmWa7x04pClbo/0BN9Q58ZJT8PsCb62W/N4= 22 | 23 | install: 24 | - sudo apt -y install libgconf2-4 # TODO: Remove once Atom 1.39 is stable 25 | - sudo apt-get --only-upgrade install libnss3 26 | 27 | addons: 28 | apt: 29 | packages: 30 | - libsecret-1-dev 31 | postgresql: '9.6' 32 | 33 | before_script: 34 | - createdb teletype-test 35 | 36 | script: 37 | - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh 38 | - chmod u+x build-package.sh 39 | - ./build-package.sh 40 | 41 | git: 42 | depth: 10 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See https://github.com/atom/teletype/releases 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [atom@github.com](mailto:atom@github.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See the [Atom contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md) 2 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Prerequisites 10 | 11 | * [ ] Put an X between the brackets on this line if you have done all of the following: 12 | * Followed all applicable steps in the [debugging guide][debugging-guide] 13 | * Checked the FAQs on the [message board][message-board] for common solutions 14 | * Checked that your issue [isn't already filed][already-filed]? 15 | 16 | [debugging-guide]: http://flight-manual.atom.io/hacking-atom/sections/debugging/ 17 | [message-board]: https://discuss.atom.io/c/faq 18 | [already-filed]: https://github.com/atom/teletype/issues 19 | 20 | ### Description 21 | 22 | [Description of the issue] 23 | 24 | ### Steps to Reproduce 25 | 26 | 1. [First Step] 27 | 2. [Second Step] 28 | 3. [and so on...] 29 | 30 | **Expected behavior:** 31 | 32 | [What did you expect to happen?] 33 | 34 | **Actual behavior:** 35 | 36 | [What actually happened instead?] 37 | 38 | **Reproduces how often:** 39 | 40 | [What percentage of the time does this happen?] 41 | 42 | ### Versions 43 | 44 | You can get this information from copy and pasting the output of `atom --version` and `apm --version` from the command line. Also, please include the OS and what version of the OS you're running. 45 | 46 | ### Additional Information 47 | 48 | Please include any additional information, configuration or data that might be necessary to reproduce the issue. Screenshots, if appropriate, are helpful as well. 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2018 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Please be sure to read the [contributor's guide](https://github.com/atom/teletype/blob/master/CONTRIBUTING.md) before submitting any pull requests.** 2 | 3 | ### Requirements 4 | 5 | * Filling out the template is required. Any pull request that does not include enough information to be reviewed in a timely manner may be closed at the maintainers' discretion. 6 | * All new code requires tests to ensure against regressions. 7 | 8 | ### Description of the Change 9 | 10 | 15 | 16 | ### Alternate Designs 17 | 18 | 19 | 20 | ### Benefits 21 | 22 | 23 | 24 | ### Possible Drawbacks 25 | 26 | 27 | 28 | ### Verification Process 29 | 30 | 41 | 42 | ### Applicable Issues 43 | 44 | 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # Teletype for Atom 3 | 4 | An Atom package that lets developers share their workspace with team members and collaborate on code in real time. 5 | 6 | Learn more at [teletype.atom.io](https://teletype.atom.io). 7 | 8 | ![demo](https://user-images.githubusercontent.com/2988/32753167-d781baf0-c899-11e7-8b64-683ab84d3a8c.gif) 9 | 10 | ## Installation 11 | 12 | ### Command Line 13 | 14 | 1. Install [Atom 1.22](https://atom.io) or newer 15 | 2. In the terminal, install the package via apm: 16 | 17 | ```sh 18 | apm install teletype 19 | ``` 20 | 21 | ### GUI 22 | 23 | 1. Install [Atom 1.22](https://atom.io) or newer 24 | 1. Launch Atom 25 | 1. Open Settings View using Cmd+, on macOS or Ctrl+, on other platforms 26 | 1. Click the Install tab on the left side 27 | 1. Enter `teletype` in the search box and press Enter 28 | 1. Click the "Install" button that appears 29 | 30 | ## Hacking 31 | 32 | This package is powered by three main components: 33 | 34 | - [teletype-crdt](https://github.com/atom/teletype-crdt): The string-wise sequence CRDT that enables peer-to-peer collaborative editing. 35 | - [teletype-server](https://github.com/atom/teletype-server): The server-side application that facilitates peer discovery. 36 | - [teletype-client](https://github.com/atom/teletype-client): The editor-agnostic library that manages the interaction with other clients. 37 | 38 | ### Dependencies 39 | 40 | To run teletype tests locally, you'll first need to have: 41 | 42 | - Atom 1.22 or later 43 | - Node 7+ 44 | - PostgreSQL 9.x 45 | 46 | ### Running locally 47 | 48 | 1. Clone and bootstrap 49 | 50 | ``` 51 | git clone https://github.com/atom/teletype.git 52 | cd teletype 53 | createdb teletype-test 54 | apm install 55 | ``` 56 | 57 | 2. Run the tests 58 | 59 | ``` 60 | atom --test test 61 | ``` 62 | -------------------------------------------------------------------------------- /doc/rfcs/000-template.md: -------------------------------------------------------------------------------- 1 | # Feature title 2 | 3 | ## Status 4 | 5 | Proposed 6 | 7 | ## Summary 8 | 9 | One paragraph explanation of the feature. 10 | 11 | ## Motivation 12 | 13 | Why are we doing this? What use cases does it support? What is the expected outcome? 14 | 15 | ## Explanation 16 | 17 | Explain the proposal as if it was already implemented in the Teletype package and you were describing it to an Atom user. That generally means: 18 | 19 | - Introducing new named concepts. 20 | - Explaining the feature largely in terms of examples. 21 | - Explaining any changes to existing workflows. 22 | 23 | ## Drawbacks 24 | 25 | Why should we *not* do this? 26 | 27 | ## Rationale and alternatives 28 | 29 | - Why is this approach the best in the space of possible approaches? 30 | - What other approaches have been considered and what is the rationale for not choosing them? 31 | - What is the impact of not doing this? 32 | 33 | ## Unresolved questions 34 | 35 | - What unresolved questions do you expect to resolve through the RFC process before this gets merged? 36 | - What unresolved questions do you expect to resolve through the implementation of this feature before it is released in a new version of the package? 37 | - What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC? 38 | -------------------------------------------------------------------------------- /doc/rfcs/001-allow-guests-to-open-multiple-remote-buffers.md: -------------------------------------------------------------------------------- 1 | # Allow guests to view multiple remote buffers 2 | 3 | ## Status 4 | 5 | Implemented. See [#268](https://github.com/atom/teletype/issues/268). 6 | 7 | ## Summary 8 | 9 | Allow a separate guest editor for each open buffer in the host's workspace. 10 | 11 | ## Motivation 12 | 13 | Currently, guests can only view host buffers through a specialized "portal editor" that automatically switches its contents to track the active editor of the host. This automatic switching can interrupt guests in the middle of typing. It also makes it impossible for guests to edit any buffer other than the one the host is actively editing. 14 | 15 | Ideally, guests would be able to edit any file in the host's current project, but that will require replication of the file-system which is beyond the scope of this proposal. We can get much closer to that ideal by giving guests access to any open buffer in the host's workspace. This can be implemented without substantial changes to the current replication architecture. 16 | 17 | ## Explanation 18 | 19 | ### Remote buffers 20 | 21 | Just as guests can open editors for local buffers corresponding to files on their local file system, they can also open editors for *remote buffers* corresponding to buffers in any remote workspace that they have joined. 22 | 23 | When the host closes the portal, all the remote buffers in the guest's workspace are preserved, but will become untitled and unsaved (just as they did prior to this RFC). 24 | 25 | ### Following 26 | 27 | When you first join a portal as a guest, you're automatically *following* the host. This means that you jump to the host's current position and continue to follow them as they move between buffers. 28 | 29 | In order to jump to the host's current position, an editor is automatically added to your workspace for their active buffer when you first join their workspace. If you continue to follow the host, any time they switch to a new buffer, a new editor for that remote buffer is automatically added to your workspace and focused. Existing editors for previous remote buffers are not automatically closed when this switch occurs. 30 | 31 | When a host closes a buffer, it will be removed from all guest portals. 32 | 33 | 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. 34 | 35 | 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. All avatars appear in the bottom right of the editor. If the participant's active pane item is an editor associated with the portal, you can click on the participant's avatar to follow them. If the participant is working in a pane item not associated with the portal, you can't follow that participant to their current location. 36 | 37 | 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... 38 | 39 | ### Navigation 40 | 41 | As a guest of a shared workspace, you don't automatically receive access to the host's file system. Tools like the tree view, the file finder, and project search don't have access to their current project. (This capability will be the subject of an upcoming RFC). 42 | 43 | For now, however, the file finder *will* be augmented to make it easy for you to navigate any buffer that the host currently has open in their workspace. These remote buffers will be decorated with the avatar of the host that owns their workspace. 44 | 45 | ## Drawbacks 46 | 47 | Currently, a portal always corresponds to a single tab. This keeps things really simple. To leave the portal, you just close the tab. Now there could be multiple editors in your workspace that are part of a remote workspace, which increases complexity. 48 | 49 | When you follow someone between multiple buffers, editors for remote buffers may start to stack up in your workspace. Some may consider this to be too cluttered. If this becomes problematic, we could add an option to automatically close an editor upon following a collaborator to a different buffer or just make that the default behavior. 50 | 51 | ## Rationale and alternatives 52 | 53 | The workspace and the project are *both* pieces of state worth sharing. The workspace is more transient, representing a "working set" of resources that a developer is looking at *right now*. Ideally, any resource in this working set should be able to be accessed by collaborators. This includes the set of open buffers, but it could also include other state such as a terminal session, a debugger, console output, etc. 54 | 55 | We want to *also* give access to a host's entire project, which represents a set of file system directories they're currently working on, a much larger set of resources than their current working set. However, even when we do add this feature, there's a role for being able to access any buffer a host has open in their workspace, because not every buffer corresponds to a file in a project. The host could open any file on their file system without adding its parent folder to their project, and we still want to be able to collaborate on it. 56 | 57 | Sharing a workspace is a complement to sharing a project, and since it is technically simpler than sharing a project and we already have the infrastructure in place to add this, it's worth doing soon. 58 | 59 | ## Unresolved questions 60 | 61 | Should we retain the vocabulary of "portals", or is it simpler to just refer to this state as a "shared workspace."" The original concept of a portal seems to have been subsumed by the concept of "following" a collaborator in a shared workspace. 62 | -------------------------------------------------------------------------------- /doc/rfcs/002-sync-buffer-path-changes-from-host-to-guest.md: -------------------------------------------------------------------------------- 1 | # Sync buffer path changes from host to guest 2 | 3 | ### Status 4 | 5 | Implemented. See [#352](https://github.com/atom/teletype/issues/352). 6 | 7 | ### Summary 8 | 9 | We want to add the functionality to have title changes of text-buffers from the host using Teletype to be reflected in the guests' buffers. 10 | 11 | ### Motivation 12 | 13 | As described in [#147](https://github.com/atom/teletype/issues/147), when the host renames a file, the guest should see those changes reflected in their workspace as well. 14 | 15 | ### Explanation 16 | 17 | There is a new class in _Teletype/buffer-binding.js_, called _RemoteFile_. This File acts as a filler for the `File` object. It implements: 18 | 19 | 1. `constructor({uri})` 20 | 2. `dispose()` 21 | 3. `getPath()` returns the path using the uri configured by `getPathWithNativeSeparators`. 22 | 4. `setURI(uri)` sets the new uri and emits `did-rename`. 23 | 5. `onDidRename(callback)` allows _atom/TextBuffer_ to listen to then emit `path-did-change` messages. 24 | 6. `existsSync()` required function for filler file objects; returns `False`. 25 | 26 | This will add an additional workflow to the _Teletype_ process: 27 | 28 | 1. When the guest initializes their workspace, _teletype/BufferBinding_'s `setBufferProxy` function makes a new `RemoteFile` with _BufferProxy_'s `uri`. It then calls `buffer.setFile()` on this new object. 29 | 2. A subscription is added to _teletype/BufferBinding_ to capture when the _TextBuffer_'s path changes. This subscription, when triggered will call `relayURIChange()`, which will update the host's _BufferProxy_'s URI. `relayURIChange` sets the new URI by calling `setURI` in _BufferProxy_ using the results of `getBufferProxyURI`. 30 | 3. The Host's _BufferProxy_ will use the `BufferProxyUpdate` schema to relay changes to all of the Guest's _BufferProxy_s to change their `URI`s. Then it will update its own `URI`. 31 | 4. The Guest's _BufferProxy_, upon getting the update message will invoke the _teletype/BufferBinding_'s `didChangeURI` function. 32 | 5. This calls `setURI` in `RemoteFile` to update its `URI` and emits a `did-rename` message, which causes _atom/TextBuffer_ to send a `did-change-path` message. 33 | 6. The Guest's _teletype/EditorBinding_'s monkey bindings are updated such that the URI constant is removed, and is updated when `getTitle()` is invoked. 34 | 35 | ### Drawbacks 36 | 37 | We have not yet seen any drawbacks to this. 38 | 39 | ### Rationale and alternatives 40 | 41 | > Why is this approach the best in the space of possible approaches? 42 | 43 | We believe that this is the best approach to this issue, since it is the most direct way to notify the guests of this change using the current structure of Atom and Teletype. 44 | 45 | >What other approaches have been considered and what is the rationale for not choosing them? 46 | 47 | We used the process in place in the Teletype package. 48 | 49 | >What is the impact of not doing this? 50 | 51 | When the host saves a new file, the host will see the the buffer's title and path updated to reflect the new filename, but guests will continue to see the buffer identified as "untitled" in their workspaces. 52 | 53 | Similarly, when the host renames a file (e.g., from `foo.txt` to `foo.md`), the host will see the new filename reflected in the UI, and the host will see the new grammar applied to the editor, but guests will continue to see the old filename and its old grammar. 54 | -------------------------------------------------------------------------------- /doc/rfcs/003-share-and-join-a-portal-via-url.md: -------------------------------------------------------------------------------- 1 | # Share and join a portal via URL 2 | 3 | ## Status 4 | 5 | Implemented. See [#109](https://github.com/atom/teletype/issues/109). 6 | 7 | ## Summary 8 | 9 | A host can share a URL for the portal, and guests can follow the URL to instantly join the portal. Users will typically share these URLs via a third-party chat service like Slack, IRC, or similar. 10 | 11 | ## Motivation 12 | 13 | We hope to encourage more collaboration by reducing the barriers to entry. Specifically, we want to reduce the number of steps that exist between A) deciding that you *want* to collaborate and B) *actually* collaborating. 14 | 15 | Today, the transition from A to B involves the following steps: 16 | 17 | 1. Host shares a portal 18 | 2. Host copies portal ID to their clipboard 19 | 3. Host switches from Atom to third-party communication tool (e.g., Slack, IRC) 20 | 4. Host pastes portal ID into third-party communication tool 21 | 5. Guest selects portal ID and copies it to their clipboard 22 | 6. Guest switches from third-party communication tool to Atom 23 | 7. Guest clicks "Join a portal" link or invokes "Join portal" command, and Atom automatically fetches the portal ID from the clipboard 24 | 8. Guest clicks "Join" 25 | 26 | With the ability to join a portal via a URL, we can reduce the guest's set-up process from 4 steps to just 1 step: 27 | 28 | 1. Host shares a portal 29 | 2. Host copies portal URL to their clipboard 30 | 3. Host switches from Atom to third-party communication tool (e.g., Slack, IRC) 31 | 4. Host pastes portal URL into third-party communication tool 32 | 5. Guest clicks portal URL, and the operating system hands control to Atom, and Atom joins the portal 33 | 34 | ## Explanation 35 | 36 | ### Share a portal via URL 37 | 38 | Once a host creates a portal (e.g., by clicking the "share" toggle in Teletype's status bar popover), Teletype presents the host with the portal's URL. The host copies that URL to their clipboard (in the same way that they previously copied the portal ID). 39 | 40 | #### Portal URL format 41 | 42 | Prior Teletype releases established a URI structure for identifying editors in Atom's workspace: `atom://teletype/portal//editor/` 43 | 44 | Portal URLs use that same pattern, but exclude the editor ID: `atom://teletype/portal/` 45 | 46 | The `` is the same ID used in prior releases for sharing and joining a portal. 47 | 48 | Example: `atom://teletype/portal/63b120f3-b646-4c46-8962-656518249186` 49 | 50 | ### Join a portal via URL 51 | 52 | When a guest follows the URL (e.g., by clicking on the URL in Slack, IRC, etc.), Atom opens, and Teletype asks the user if they want to join the portal. If the user chooses to join the portal, they become a guest in the portal (just as they previously did by entering the portal ID and clicking "Join"). If they choose not to join the portal, nothing happens. 53 | 54 | To honor the [UX guidelines for Atom URI handlers](https://flight-manual.atom.io/hacking-atom/sections/handling-uris/), Teletype avoids automatically joining the portal. When asking the user whether they want to join the portal, Teletype offers an option to automatically join future portals. This option is disabled by default. When the user enables this option, any time they follow a portal URL, Teletype will automatically join the portal without the user having to perform an additional confirmation of their desire to join the portal. Users can disable this option at any time via the Teletype settings in Atom's Settings UI. 55 | 56 | ## Drawbacks 57 | 58 | If the host has upgraded to the new version of the package and the guest still has the old version, nothing will happen when the guest follows the portal URL (since the old version doesn't have support for handling URLs). To avoid this potential cause for confusion, we'll bump the protocol version so that everybody is prompted to upgrade to the latest teletype version before they're able to continue using Teletype. 59 | 60 | ## Rationale and alternatives 61 | 62 | ##### Why is this approach the best in the space of possible approaches? 63 | 64 | N/A: The featured shipped before we completed the RFC. 😇 65 | 66 | ##### What other approaches have been considered and what is the rationale for not choosing them? 67 | 68 | N/A: The featured shipped before we completed the RFC. 😇 69 | 70 | ##### What is the impact of not doing this? 71 | 72 | People will collaborate less often. Given the additional steps needed to start collaborating, there will be more instances where people decide that it's not worth the effort (i.e., people will enjoy rich collaboration less often). 73 | 74 | ## Unresolved questions 75 | 76 | ##### What unresolved questions do you expect to resolve through the RFC process before this gets merged? 77 | 78 | None. 79 | 80 | ##### What unresolved questions do you expect to resolve through the implementation of this feature before it is released in a new version of the package? 81 | 82 | None. 83 | 84 | ##### What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC? 85 | 86 | - Offering to install Teletype if you click on a portal URL and don't yet have Teletype installed ([#220](https://github.com/atom/teletype/issues/220)) (e.g., potentially using https://atom.io for the portal URLs) 87 | - Providing option to join portal in a new window [[discussion](https://github.com/atom/teletype/pull/344#discussion_r175569812)] 88 | -------------------------------------------------------------------------------- /doc/rfcs/004-quickly-collaborate-with-coworkers-and-friends.md: -------------------------------------------------------------------------------- 1 | # Quickly collaborate with coworkers and friends 2 | 3 | ## Status 4 | 5 | Proposed 6 | 7 | ## Summary 8 | 9 | A user can add any of their recent collaborators to a list of "trusted collaborators." Once two people have added each other to their list of trusted collaborators, they can establish future collaboration sessions with each other from directly within Atom. 10 | 11 | ## Motivation 12 | 13 | We hope to encourage more collaboration by reducing the barriers to entry. Specifically, we want to reduce the number of steps that exist between A) deciding that you *want* to collaborate and B) *actually* collaborating. 14 | 15 | Today, the transition from A to B involves the following steps: 16 | 17 | 1. Host shares a portal 18 | 2. Host copies portal URL to their clipboard 19 | 3. Host switches from Atom to third-party communication tool (e.g., Slack, IRC) 20 | 4. Host pastes portal URL into third-party communication tool 21 | 5. Guest clicks portal URL, and the operating system hands control to Atom, and Atom joins the portal 22 | 23 | With the ability to invite trusted collaborators to your portal from within Atom, we reduce the set-up process from 5 steps to just 3 steps: 24 | 25 | 1. Host selects a person in their list of trusted collaborators 26 | 2. Host invites the selected person to a portal, and Atom automatically shares a portal for the host and invites the selected person 27 | 3. Guest accepts invitation to join portal, and Atom joins the portal 28 | 29 | ## Explanation 30 | 31 | ### Accept portal invitations from a trusted collaborator 32 | 33 | Teletype provides a list of your recent collaborators. Each time a guest joins your portal, Teletype adds them to your list of recent collaborators. Each time you join a portal, Teletype adds the host to your list of recent collaborators. 34 | 35 | You can select a past collaborator and inform Teletype that you're willing to allow them to see when you're online [A], and that you're willing to receive portal invitations from them directly within Atom, thus identifying them as a "trusted collaborator." 36 | 37 | ![](https://user-images.githubusercontent.com/378023/38798126-a2ccb7f8-419b-11e8-92d2-df0b57f311ec.png) 38 | 39 | You can also choose to remove a person from your list of recent collaborators. [[motivation](https://github.com/atom/teletype/pull/344#discussion_r182509071)] 40 | 41 | ## Invite a trusted collaborator to your portal 42 | 43 | Teletype presents your list of trusted collaborators sorted alphabetically by username. 44 | 45 | If you have designated a person as a trusted collaborator, and they have also designated you as a trusted collaborator, then you can see whether they're online [A], and they can see whether you're online. 46 | 47 | You can select a trusted collaborator that is currently online (an "invitee" in this context) and invite them to join your portal. If you're not already hosting a portal, Teletype automatically creates a portal for your workspace. 48 | 49 | ![](https://user-images.githubusercontent.com/378023/38798163-c1dc4226-419b-11e8-9b96-5db9a9c90221.png) 50 | 51 | If the invitee chooses to join the portal, the portal opens in the invitee's workspace (just as it does when joining a portal via a URL). 52 | 53 | If the invitee declines to join the portal, Teletype notifies you that the guest declined your invitation. You cannot re-invite that invitee to the same portal. (There's probably a reason why they didn't join, so there's no need to bug them even more. If you think they declined by accident, reach out to them via a third-party chat service and send them the URL to join your portal.) 54 | 55 | ![](https://user-images.githubusercontent.com/378023/38798235-f85f6e86-419b-11e8-9508-8d6a97b541f1.png) 56 | 57 | ## Stop accepting invitations from a trusted collaborator 58 | 59 | To stop receiving invitations from a trusted collaborator, you can remove them from your list of trusted collaborators. Once you've removed someone from your list of trusted collaborators, you will appear as "offline" to them, and they can no longer invite you to their portal from directly within Atom. 60 | 61 | There are two ways to remove someone from your list of trusted collaborators: 62 | 63 | - When you decline an invitation to join a portal, Teletype presents the to option to block future invitations from that person (i.e., to remove them from your trusted collaborators). 64 | 65 | ![](https://user-images.githubusercontent.com/378023/38803217-79518b02-41a9-11e8-9f07-0f0085fc994f.png) 66 | 67 | - At any time, you can right-click a person in your list of trusted collaborators and remove them: 68 | 69 | ![](https://user-images.githubusercontent.com/378023/38803345-e0087c3e-41a9-11e8-8c59-23c5a9a38bb9.png) 70 | 71 | If you decide that you want to collaborate with them again, you can send them a URL (via Slack, IRC, etc.) to join your portal, or you can ask them to send you a URL (via Slack, IRC, etc.) so that you can join their portal, or you can add them back to your list of trusted collaborators. 72 | 73 | ## Additional privacy and safety considerations 74 | 75 | 1. **Opt-in to receiving invitations (or don't)** - Choosing to accept in-Atom portal invitations is entirely opt-in. You'll only receive portal invitations from trusted collaborators. You control this "whitelist" and can change it at any time. Teletype won't show you any kind of invitation or request from people outside your list of trusted collaborators. [[motivation](https://github.com/atom/teletype/pull/344#pullrequestreview-105405268)] 76 | 2. **Opt-out safely at any time** - At any time, you can remove a person from your list of trusted collaborators. 77 | 1. You'll no longer receive invitations from them within Atom. 78 | 2. They observe no indication that you have removed them. You simply appear offline to them, just as you appeared when you were actually offline, and just as you appeared to them before you first added them as a trusted collaborator. [[motivation](https://github.com/atom/teletype/pull/344#discussion_r175911322)] 79 | 3. Because they observe no indication that you have removed them, they may still have you in their list of trusted collaborators (i.e., their list of people that _they_ are willing to accept in-Atom portal invitations from). But because you appear offline to them, they cannot invite you to join their portal. 80 | 81 | ### Mockups 82 | 83 | #### Viewing collaborator states 84 | 85 | You can see the collaborators that are currently in your portal, the state of the other invitations that you've sent, and the online/offline state of all your trusted collaborators: 86 | 87 | ![](https://user-images.githubusercontent.com/378023/38805994-dc3438ba-41b2-11e8-83b4-10c97a039e4e.png) 88 | 89 | #### Max height 90 | 91 | The list of trusted and rencent collaborators has a `max-height`. If both lists combined exceeds the `max-height`, you can scroll. 92 | 93 | ![](https://user-images.githubusercontent.com/378023/38806124-32fb5412-41b3-11e8-9a11-4e921cb0a160.png) 94 | 95 | #### Simultaneously participating in multiple portals 96 | 97 | While probably not very common, it's possible to host a portal while also participating as a guest in other portals. 98 | 99 | ![](https://user-images.githubusercontent.com/378023/38806255-a5380bec-41b3-11e8-9c73-b1f598158120.png) 100 | 101 | ## Out of scope 102 | 103 | In the interest of getting the highest impact functionality in users' hands as quickly as possible and then iterating based on real-world feedback, the following functionality is out of scope for this RFC, but may be addressed in follow-up releases. 104 | 105 | 1. Changing your online/offline status [A] 106 | - Setting your status to offline. (In the meantime, you can sign out of Teletype or disable Teletype in Atom's package settings when you want to use Atom while not accepting invitations from your trusted collaborators.) 107 | - Setting your status to "away" or "busy" 108 | 2. UX enhancements to the list of recent collaborators and trusted collaborators 109 | - Changing the sort order for your list of collaborators (e.g., sort by how recently you've collaborated) 110 | - Limiting the size of your list of trusted collaborators. (In the meantime, you can remove trusted collaborators to reduce the size of the list.) 111 | - Filtering your list of trusted collaborators 112 | - Marking certain trusted collaborators as "favorites" 113 | 3. Selecting a trusted collaborator and asking to join their portal 114 | 4. Selecting multiple trusted collaborators and inviting them to your portal simultaneously. (In the meantime, you can select a collaborator and invite them, then select another collaborator and invite them, and so on.) 115 | 5. Providing option to join portal in a new window [[discussion](https://github.com/atom/teletype/pull/344#discussion_r175569812)] 116 | 6. Automatically preventing portal invitations from trusted collaborators that have been flagged as suspended/spammy on github.com 117 | 118 | ## Drawbacks 119 | 120 | 1. Once Teletype allows you to designate a list of trusted collaborators, people may want to be able to chat with those collaborators from within Teletype. While we may eventually want to support chat, any chat-related functionality must exist in service of Teletype's primary vision of "making it as easy to code together as it is to code alone." We'll need to be diligent to avoid scope creep. 121 | 2. Today, when a host shares a portal, the host sends their peer information to Teletype so that guests can query Teletype to determine how to connect to the host. Teletype has no peering information for other users (e.g., users that a host might want to invite to their portal). In order for Teletype to communicate a portal invitation to a user, Teletype will need some way of sending a message to the user. Depending on the technical solution we choose, we may incur increased server-side resource consumption and/or we may take on additional operational complexity. 122 | 123 | ## Rationale and alternatives 124 | 125 | ##### Why is this approach the best in the space of possible approaches? 126 | 127 | With the approach described above, we believe Teletype can provide a more streamlined set-up process while also preventing harassment. To establish a collaboration session with a coworker or friend directly within Atom, we each add each other as trusted collaborators, and then we can start collaborating at any time with just a couple clicks. With this mutual consent model, you're in complete control of which users can communicate with you. 128 | 129 | ##### What other approaches have been considered and what is the rationale for not choosing them? 130 | 131 | 1. **Invite anyone to collaborate by username**: Teletype could allow you to enter a person's GitHub username (or their email address) to invite them to your portal. This would remove the need for sharing a URL via a third-party service in order to collaborate, and it would reduce the need to maintain a list of trusted collaborators (i.e., you could just enter a username each time you want to collaborate). However, it would make it possible for any GitHub user to cause invitations to appear inside your Atom instance. This introduces a vector for harassment, so we're avoiding this approach. 132 | 2. **Invite anyone to collaborate by username, and ignore invitations from blocked users**: To reduce the harassment concerns described in the previous bullet, Teletype could require that you grant it additional permissions so that it can read your [blocked user settings](https://developer.github.com/v3/users/blocking/) to determine whether to show you an invitation from a particular user. With this approach, if a user blocks a person on github.com, Teletype could automatically prevent invitations from the blocked person. However, this approach introduces tradeoffs that we'd prefer to avoid: 133 | - Users would need to grant Teletype substantially greater permissions in order for Teletype to check users' block lists. Due to coarse-grained OAuth scopes, users would need to [grant Teletype permission to read/write their private email addresses, update their github.com profile, etc.][user-scope-request] Teletype would no longer be able to simply require [read-only permission to each user's public info][scopeless-request]. 134 | - Some users would still likely receive unwanted portal invitations from users they haven't formally blocked. A popular developer might receive a ton of unwanted invitations, and/or a harasser might send invitations to people that haven't yet blocked them. Because of this, Teletype would likely still need to provide some additional safety/spam prevention on top of integrating with the user's github.com block list. 135 | 3. **Ask for permission to send in-Atom portal invitations to a user**: To avoid seeing in-Atom portal invitations from the general public, Teletype could require that you first request a person's permission to send them portal invitations [[discussion](https://github.com/atom/teletype/pull/344#issuecomment-375663822)]. (This approach is similar to a "friend request" on Facebook.) To avoid receiving friend requests from users that you've blocked on github.com, Teletype would need permission to read your block list. This approach therefore suffers from the same issues described in the previous bullet. 136 | 4. **Invite any fellow org member to collaborate**: Teletype could allow you to invite other users that you're already associated with in some way (e.g., fellow collaborators on a GitHub repository, fellow members of a GitHub organization or team). This would remove the need for sharing a URL via a third-party service in order to collaborate, and it would reduce the need to maintain a list of trusted collaborators, and it would reduce the harassment vector described in the previous bullets. However, it would introduce tradeoffs that we'd prefer to avoid: 137 | - Teletype would need additional permissions (i.e., [OAuth scopes](https://developer.github.com/apps/building-oauth-apps/scopes-for-oauth-apps/#available-scopes)) to fetch the list of users you're associated with. (Today, Teletype uses a scopeless token and therefore has no access to your private information.) 138 | - Some users belong to organizations with thousands of members. Just because you're a member of the same organization as someone else doesn't mean that you're comfortable seeing portal invitations from them. 139 | 5. **Ask for public data permission first, and only ask for more permissions if you want to receive in-Atom portal invitations.** Several of the alternatives above require Teletype to request additional permissions. Because those permissions are only needed to control which users can send you in-Atom portal invitations, Teletype could initially request the existing permissions (i.e., [read-only permission to your public info][scopeless-request]), and Teletype could defer asking for [additional permissions][user-scope-request] until you opt in to receiving in-Atom portal invitations. Users that are turned off by the request for greater permissions could continue using Teletype with URLs to establish collaboration sessions. This approach would allow Teletype to prevent invitations from users that you've blocked on github.com. However, just because you haven't blocked a user doesn't mean that you're comfortable with that user seeing your online/offline status; we'd still need some other mechanism for controlling who can see your online/offline status. 140 | 6. **Add support for audio before adding in-Atom portal invitations**: Teletype could add support for audio before adding support for establishing collaboration sessions directly within Atom. When collaborating with people in different physical locations, you'll likely need audio support in order to collaborate. Since Teletype doesn't currently provide audio support, these collaborators will still need a third-party solution for audio. If you're already relying on a third-party solution for audio, you can often use that same solution to send your collaborators your portal URL, which reduces the need for Teletype to maintain a list of trusted collaborators. However, there are still many [scenarios where it's helpful to quickly invite a trusted collaborator even if Teletype doesn't yet support audio](https://github.com/atom/teletype/pull/344#discussion_r175319913). 141 | 142 | ##### What is the impact of not doing this? 143 | 144 | People will collaborate less often. Given the additional steps needed to start collaborating, there will be more instances where people decide that it's not worth the effort (i.e., people will enjoy rich collaboration less often). 145 | 146 | ## Unresolved questions 147 | 148 | ##### What unresolved questions do you expect to resolve through the RFC process before this gets merged? 149 | 150 | - If I invite a trusted collaborator to join my portal, and that person has multiple Atom windows open, can we show the invitation only in the frontmost window? Similarly, if that person has Atom windows open on multiple computers, are we OK with showing the invitation on each computer? _Answer_: The invitation will appear in all open Atom windows where the user is signed into Teletype. Once the user accepts or declines the invitation on in one Atom window, Teletype will dismiss the invitation in the other Atom windows. [[discussion](https://github.com/atom/teletype/pull/344#pullrequestreview-105123633)] 151 | 152 | ##### What unresolved questions do you expect to resolve through the implementation of this feature before it is released in a new version of the package? 153 | 154 | - What architecture/services/libraries will we use to communicate portal invitations? 155 | - In order to provide the functionality described above, does teletype-server need to persist your list of trusted collaborators, or can we meet these needs while only storing this data locally? 156 | - If the host invites a trusted collaborator to their portal and then closes the portal before the invitee joins the portal, what should an invitee see/experience to inform them that the portal no longer exists? 157 | 158 | ##### What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC? 159 | 160 | See [out of scope](#out-of-scope) section above. 161 | 162 | --- 163 | 164 | [A] A user is **online** if they have Atom open, and Teletype is installed, and they are signed into Teletype, and their network allows them to successfully communicate with `api.teletype.atom.io`. In all other situations, the user is considered to be **offline**. 165 | 166 | [scopeless-request]: https://user-images.githubusercontent.com/2988/38115271-65e65928-3379-11e8-9945-4a15ceb857fe.png 167 | 168 | [user-scope-request]: https://user-images.githubusercontent.com/2988/38115272-65f69784-3379-11e8-8753-4da6547c7edb.png 169 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const TeletypePackage = require('./lib/teletype-package') 2 | module.exports = new TeletypePackage({ 3 | config: atom.config, 4 | workspace: atom.workspace, 5 | notificationManager: atom.notifications, 6 | packageManager: atom.packages, 7 | commandRegistry: atom.commands, 8 | tooltipManager: atom.tooltips, 9 | clipboard: atom.clipboard, 10 | pusherKey: atom.config.get('teletype.dev.pusherKey'), 11 | pusherOptions: { 12 | cluster: atom.config.get('teletype.dev.pusherCluster'), 13 | disableStats: true 14 | }, 15 | baseURL: atom.config.get('teletype.dev.baseURL'), 16 | getAtomVersion: atom.getVersion.bind(atom) 17 | }) 18 | -------------------------------------------------------------------------------- /lib/authentication-provider.js: -------------------------------------------------------------------------------- 1 | const {Emitter} = require('atom') 2 | 3 | module.exports = 4 | class AuthenticationProvider { 5 | constructor ({client, notificationManager, workspace, credentialCache}) { 6 | this.client = client 7 | this.client.onSignInChange(this.didChangeSignIn.bind(this)) 8 | this.credentialCache = credentialCache 9 | this.notificationManager = notificationManager 10 | this.workspace = workspace 11 | this.emitter = new Emitter() 12 | } 13 | 14 | async signInUsingSavedToken () { 15 | if (this.isSignedIn()) return true 16 | 17 | const token = await this.credentialCache.get('oauth-token') 18 | if (token) { 19 | return this._signIn(token) 20 | } else { 21 | return false 22 | } 23 | } 24 | 25 | async signIn (token) { 26 | if (this.isSignedIn()) return true 27 | 28 | if (await this._signIn(token)) { 29 | await this.credentialCache.set('oauth-token', token) 30 | return true 31 | } else { 32 | return false 33 | } 34 | } 35 | 36 | async signOut () { 37 | if (!this.isSignedIn()) return 38 | 39 | await this.credentialCache.delete('oauth-token') 40 | this.client.signOut() 41 | } 42 | 43 | async _signIn (token) { 44 | try { 45 | this.signingIn = true 46 | this.didChangeSignIn() 47 | 48 | const signedIn = await this.client.signIn(token) 49 | return signedIn 50 | } catch (error) { 51 | this.notificationManager.addError('Failed to authenticate to teletype', { 52 | description: `Signing in failed with error: ${error.message}`, 53 | dismissable: true 54 | }) 55 | } finally { 56 | this.signingIn = false 57 | this.didChangeSignIn() 58 | } 59 | } 60 | 61 | isSigningIn () { 62 | return this.signingIn 63 | } 64 | 65 | isSignedIn () { 66 | return this.client.isSignedIn() 67 | } 68 | 69 | getIdentity () { 70 | return this.client.getLocalUserIdentity() 71 | } 72 | 73 | onDidChange (callback) { 74 | return this.emitter.on('did-change', callback) 75 | } 76 | 77 | didChangeSignIn () { 78 | const workspaceElement = this.workspace.getElement() 79 | if (this.isSignedIn()) { 80 | workspaceElement.classList.add('teletype-Authenticated') 81 | } else { 82 | workspaceElement.classList.remove('teletype-Authenticated') 83 | } 84 | 85 | this.emitter.emit('did-change') 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/buffer-binding.js: -------------------------------------------------------------------------------- 1 | const {Emitter, Range, CompositeDisposable, TextBuffer} = require('atom') 2 | const getPathWithNativeSeparators = require('./get-path-with-native-separators') 3 | const path = require('path') 4 | 5 | function doNothing () {} 6 | 7 | module.exports = 8 | class BufferBinding { 9 | constructor ({buffer, isHost, didDispose}) { 10 | this.buffer = buffer 11 | this.saveBuffer = TextBuffer.prototype.save.bind(buffer) 12 | this.isHost = isHost 13 | this.emitDidDispose = didDispose || doNothing 14 | this.pendingChanges = [] 15 | this.disposed = false 16 | this.disableHistory = false 17 | this.subscriptions = new CompositeDisposable() 18 | if (isHost) { 19 | this.subscriptions.add(buffer.onDidChangePath(this.relayURIChange.bind(this))) 20 | } 21 | } 22 | 23 | dispose () { 24 | if (this.disposed) return 25 | 26 | this.disposed = true 27 | this.subscriptions.dispose() 28 | this.buffer.restoreDefaultHistoryProvider(this.bufferProxy.getHistory(this.buffer.maxUndoEntries)) 29 | this.buffer = null 30 | if (this.bufferDestroySubscription) this.bufferDestroySubscription.dispose() 31 | if (this.remoteFile) this.remoteFile.dispose() 32 | this.emitDidDispose() 33 | } 34 | 35 | setBufferProxy (bufferProxy) { 36 | this.bufferProxy = bufferProxy 37 | this.buffer.setHistoryProvider(this) 38 | while (this.pendingChanges.length > 0) { 39 | this.pushChange(this.pendingChanges.shift()) 40 | } 41 | this.pendingChanges = null 42 | if (!this.isHost) { 43 | this.remoteFile = new RemoteFile({uri: bufferProxy.uri}) 44 | this.buffer.setFile(this.remoteFile) 45 | } 46 | this.bufferDestroySubscription = this.buffer.onDidDestroy(() => { 47 | if (this.isHost) { 48 | bufferProxy.dispose() 49 | } else { 50 | this.dispose() 51 | } 52 | }) 53 | } 54 | 55 | setText (text) { 56 | this.disableHistory = true 57 | this.buffer.setTextInRange(this.buffer.getRange(), text) 58 | this.disableHistory = false 59 | } 60 | 61 | pushChange (change) { 62 | if (this.disableHistory) return 63 | 64 | if (this.bufferProxy) { 65 | const {oldStart, oldEnd, newText} = change 66 | this.bufferProxy.setTextInRange(oldStart, oldEnd, newText) 67 | } else { 68 | this.pendingChanges.push(change) 69 | } 70 | } 71 | 72 | pushChanges (changes) { 73 | if (this.disableHistory) return 74 | 75 | for (let i = changes.length - 1; i >= 0; i--) { 76 | this.pushChange(changes[i]) 77 | } 78 | } 79 | 80 | updateText (textUpdates) { 81 | for (let i = textUpdates.length - 1; i >= 0; i--) { 82 | const {oldStart, oldEnd, newText} = textUpdates[i] 83 | this.disableHistory = true 84 | this.buffer.setTextInRange(new Range(oldStart, oldEnd), newText) 85 | this.disableHistory = false 86 | } 87 | } 88 | 89 | undo () { 90 | const result = this.bufferProxy.undo() 91 | if (result) { 92 | this.convertMarkerRanges(result.markers) 93 | return result 94 | } else { 95 | return null 96 | } 97 | } 98 | 99 | redo () { 100 | const result = this.bufferProxy.redo() 101 | if (result) { 102 | this.convertMarkerRanges(result.markers) 103 | return result 104 | } else { 105 | return null 106 | } 107 | } 108 | 109 | convertMarkerRanges (layersById) { 110 | for (const layerId in layersById) { 111 | const markersById = layersById[layerId] 112 | for (const markerId in markersById) { 113 | const marker = markersById[markerId] 114 | marker.range = Range.fromObject(marker.range) 115 | } 116 | } 117 | } 118 | 119 | getChangesSinceCheckpoint (checkpoint) { 120 | return this.bufferProxy.getChangesSinceCheckpoint(checkpoint) 121 | } 122 | 123 | createCheckpoint (options) { 124 | if (this.disableHistory) return 125 | 126 | return this.bufferProxy.createCheckpoint(options) 127 | } 128 | 129 | groupChangesSinceCheckpoint (checkpoint, options) { 130 | if (this.disableHistory) return 131 | 132 | return this.bufferProxy.groupChangesSinceCheckpoint(checkpoint, options) 133 | } 134 | 135 | revertToCheckpoint (checkpoint, options) { 136 | if (this.disableHistory) return 137 | 138 | const result = this.bufferProxy.revertToCheckpoint(checkpoint, options) 139 | if (result) { 140 | this.convertMarkerRanges(result.markers) 141 | return result 142 | } else { 143 | return false 144 | } 145 | } 146 | 147 | groupLastChanges () { 148 | if (this.disableHistory) return 149 | 150 | return this.bufferProxy.groupLastChanges() 151 | } 152 | 153 | applyGroupingInterval (groupingInterval) { 154 | if (this.disableHistory) return 155 | 156 | this.bufferProxy.applyGroupingInterval(groupingInterval) 157 | } 158 | 159 | enforceUndoStackSizeLimit () {} 160 | 161 | save () { 162 | if (this.buffer.getPath()) return this.saveBuffer() 163 | } 164 | 165 | relayURIChange () { 166 | this.bufferProxy.setURI(this.getBufferProxyURI()) 167 | } 168 | 169 | didChangeURI (uri) { 170 | if (this.remoteFile) this.remoteFile.setURI(uri) 171 | } 172 | 173 | getBufferProxyURI () { 174 | if (!this.buffer.getPath()) return 'untitled' 175 | const [projectPath, relativePath] = atom.workspace.project.relativizePath(this.buffer.getPath()) 176 | if (projectPath) { 177 | const projectName = path.basename(projectPath) 178 | return path.join(projectName, relativePath) 179 | } else { 180 | return relativePath 181 | } 182 | } 183 | 184 | serialize (options) { 185 | return this.serializeUsingDefaultHistoryProviderFormat(options) 186 | } 187 | 188 | serializeUsingDefaultHistoryProviderFormat (options) { 189 | const {maxUndoEntries} = this.buffer 190 | this.buffer.restoreDefaultHistoryProvider(this.bufferProxy.getHistory(maxUndoEntries)) 191 | const serializedDefaultHistoryProvider = this.buffer.historyProvider.serialize(options) 192 | 193 | this.buffer.setHistoryProvider(this) 194 | 195 | return serializedDefaultHistoryProvider 196 | } 197 | } 198 | 199 | class RemoteFile { 200 | constructor ({uri}) { 201 | this.uri = uri 202 | this.emitter = new Emitter() 203 | } 204 | 205 | dispose () { 206 | this.emitter.dispose() 207 | } 208 | 209 | getPath () { 210 | return getPathWithNativeSeparators(this.uri) 211 | } 212 | 213 | setURI (uri) { 214 | this.uri = uri 215 | this.emitter.emit('did-rename') 216 | } 217 | 218 | onDidRename (callback) { 219 | return this.emitter.on('did-rename', callback) 220 | } 221 | 222 | existsSync () { 223 | return false 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /lib/credential-cache.js: -------------------------------------------------------------------------------- 1 | const {execFile} = require('child_process') 2 | const path = require('path') 3 | 4 | const SERVICE_NAME = 'atom-teletype' 5 | 6 | class CredentialCache { 7 | async get (key) { 8 | const strategy = await this.getStrategy() 9 | return strategy.get(SERVICE_NAME, key) 10 | } 11 | 12 | async set (key, value) { 13 | const strategy = await this.getStrategy() 14 | return strategy.set(SERVICE_NAME, key, value) 15 | } 16 | 17 | async delete (key) { 18 | const strategy = await this.getStrategy() 19 | return strategy.delete(SERVICE_NAME, key) 20 | } 21 | 22 | async getStrategy () { 23 | if (!this.strategy) { 24 | if (await KeytarStrategy.isValid()) { 25 | this.strategy = new KeytarStrategy() 26 | } else if (SecurityBinaryStrategy.isValid()) { 27 | this.strategy = new SecurityBinaryStrategy() 28 | } else { 29 | console.warn('Falling back to storing credentials in memory. Auth tokens will only be stored for the lifetime of the current window.') 30 | this.strategy = new InMemoryStrategy() 31 | } 32 | } 33 | 34 | return this.strategy 35 | } 36 | } 37 | 38 | let keytar 39 | function getKeytar () { 40 | if (!keytar) { 41 | const bundledKeytarPath = path.join(atom.getLoadSettings().resourcePath, 'node_modules', 'keytar') 42 | keytar = require(bundledKeytarPath) 43 | } 44 | 45 | return keytar 46 | } 47 | 48 | class KeytarStrategy { 49 | static async isValid () { 50 | try { 51 | await getKeytar().setPassword('atom-test-service', 'test-key', 'test-value') 52 | const value = await getKeytar().getPassword('atom-test-service', 'test-key') 53 | getKeytar().deletePassword('atom-test-service', 'test-key') 54 | return value === 'test-value' 55 | } catch (err) { 56 | return false 57 | } 58 | } 59 | 60 | get (service, key) { 61 | return getKeytar().getPassword(service, key) 62 | } 63 | 64 | set (service, key, value) { 65 | return getKeytar().setPassword(service, key, value) 66 | } 67 | 68 | delete (service, key) { 69 | return getKeytar().deletePassword(service, key) 70 | } 71 | } 72 | 73 | class SecurityBinaryStrategy { 74 | static isValid () { 75 | return process.platform === 'darwin' 76 | } 77 | 78 | async get (service, key) { 79 | try { 80 | const value = await this.execSecurityBinary(['find-generic-password', '-s', service, '-a', key, '-w']) 81 | return value.trim() || null 82 | } catch (error) { 83 | return null 84 | } 85 | } 86 | 87 | set (service, key, value) { 88 | return this.execSecurityBinary(['add-generic-password', '-s', service, '-a', key, '-w', value, '-U']) 89 | } 90 | 91 | delete (service, key) { 92 | return this.execSecurityBinary(['delete-generic-password', '-s', service, '-a', key]) 93 | } 94 | 95 | execSecurityBinary (args) { 96 | return new Promise((resolve, reject) => { 97 | execFile('security', args, (error, stdout) => { 98 | if (error) { return reject(error) } 99 | return resolve(stdout) 100 | }) 101 | }) 102 | } 103 | } 104 | 105 | class InMemoryStrategy { 106 | constructor () { 107 | this.credentials = new Map() 108 | } 109 | 110 | get (service, key) { 111 | const valuesByKey = this.credentials.get(service) 112 | if (valuesByKey) { 113 | return Promise.resolve(valuesByKey.get(key)) 114 | } else { 115 | return Promise.resolve(null) 116 | } 117 | } 118 | 119 | set (service, key, value) { 120 | let valuesByKey = this.credentials.get(service) 121 | if (!valuesByKey) { 122 | valuesByKey = new Map() 123 | this.credentials.set(service, valuesByKey) 124 | } 125 | 126 | valuesByKey.set(key, value) 127 | return Promise.resolve() 128 | } 129 | 130 | delete (service, key) { 131 | const valuesByKey = this.credentials.get(service) 132 | if (valuesByKey) valuesByKey.delete(key) 133 | return Promise.resolve() 134 | } 135 | } 136 | 137 | Object.assign(CredentialCache, {KeytarStrategy, SecurityBinaryStrategy, InMemoryStrategy}) 138 | module.exports = CredentialCache 139 | -------------------------------------------------------------------------------- /lib/editor-binding.js: -------------------------------------------------------------------------------- 1 | /* global ResizeObserver */ 2 | 3 | const path = require('path') 4 | const {Range, Emitter, Disposable, CompositeDisposable, TextBuffer} = require('atom') 5 | const {getEditorURI} = require('./uri-helpers') 6 | const {FollowState} = require('@atom/teletype-client') 7 | 8 | module.exports = 9 | class EditorBinding { 10 | constructor ({editor, portal, isHost}) { 11 | this.editor = editor 12 | this.portal = portal 13 | this.isHost = isHost 14 | this.emitter = new Emitter() 15 | this.selectionsMarkerLayer = this.editor.selectionsMarkerLayer.bufferMarkerLayer 16 | this.markerLayersBySiteId = new Map() 17 | this.markersByLayerAndId = new WeakMap() 18 | this.subscriptions = new CompositeDisposable() 19 | this.preserveFollowState = false 20 | this.positionsBySiteId = {} 21 | } 22 | 23 | dispose () { 24 | if (this.disposed) return 25 | 26 | this.disposed = true 27 | this.subscriptions.dispose() 28 | 29 | this.markerLayersBySiteId.forEach((l) => l.destroy()) 30 | this.markerLayersBySiteId.clear() 31 | if (this.localCursorLayerDecoration) this.localCursorLayerDecoration.destroy() 32 | 33 | this.emitter.emit('did-dispose') 34 | this.emitter.dispose() 35 | } 36 | 37 | setEditorProxy (editorProxy) { 38 | this.editorProxy = editorProxy 39 | if (!this.isHost) { 40 | this.monkeyPatchEditorMethods(this.editor, this.editorProxy) 41 | } 42 | 43 | this.localCursorLayerDecoration = this.editor.decorateMarkerLayer( 44 | this.selectionsMarkerLayer, 45 | {type: 'cursor', class: cursorClassForSiteId(editorProxy.siteId)} 46 | ) 47 | 48 | const markers = this.selectionsMarkerLayer.getMarkers() 49 | for (let i = 0; i < markers.length; i++) { 50 | this.observeMarker(markers[i], false) 51 | } 52 | this.subscriptions.add(this.selectionsMarkerLayer.onDidCreateMarker(this.observeMarker.bind(this))) 53 | this.subscriptions.add(this.editor.element.onDidChangeScrollTop(this.editorDidChangeScrollTop.bind(this))) 54 | this.subscriptions.add(this.editor.element.onDidChangeScrollLeft(this.editorDidChangeScrollLeft.bind(this))) 55 | this.subscriptions.add(subscribeToResizeEvents(this.editor.element, this.editorDidResize.bind(this))) 56 | this.relayLocalSelections() 57 | } 58 | 59 | monkeyPatchEditorMethods (editor, editorProxy) { 60 | const remoteBuffer = editor.getBuffer() 61 | const originalRemoteBufferGetPath = TextBuffer.prototype.getPath.bind(remoteBuffer) 62 | const {bufferProxy} = editorProxy 63 | const hostIdentity = this.portal.getSiteIdentity(1) 64 | const prefix = hostIdentity ? `@${hostIdentity.login}` : 'remote' 65 | 66 | editor.getTitle = () => `${prefix}: ${path.basename(originalRemoteBufferGetPath())}` 67 | editor.getURI = () => getEditorURI(this.portal.id, editorProxy.id) 68 | editor.copy = () => null 69 | editor.serialize = () => null 70 | editor.isRemote = true 71 | 72 | let remoteEditorCountForBuffer = remoteBuffer.remoteEditorCount || 0 73 | remoteBuffer.remoteEditorCount = ++remoteEditorCountForBuffer 74 | remoteBuffer.getPath = () => `${prefix}:${originalRemoteBufferGetPath()}` 75 | remoteBuffer.save = () => { bufferProxy.requestSave() } 76 | remoteBuffer.isModified = () => false 77 | 78 | editor.element.classList.add('teletype-RemotePaneItem') 79 | } 80 | 81 | observeMarker (marker, relay = true) { 82 | if (marker.isDestroyed()) return 83 | 84 | const didChangeDisposable = marker.onDidChange(({textChanged}) => { 85 | if (textChanged) { 86 | if (marker.getRange().isEmpty()) marker.clearTail() 87 | } else { 88 | this.updateSelections({ 89 | [marker.id]: getSelectionState(marker) 90 | }) 91 | } 92 | }) 93 | const didDestroyDisposable = marker.onDidDestroy(() => { 94 | didChangeDisposable.dispose() 95 | didDestroyDisposable.dispose() 96 | this.subscriptions.remove(didChangeDisposable) 97 | this.subscriptions.remove(didDestroyDisposable) 98 | 99 | this.updateSelections({ 100 | [marker.id]: null 101 | }) 102 | }) 103 | this.subscriptions.add(didChangeDisposable) 104 | this.subscriptions.add(didDestroyDisposable) 105 | 106 | if (relay) { 107 | this.updateSelections({ 108 | [marker.id]: getSelectionState(marker) 109 | }) 110 | } 111 | } 112 | 113 | async editorDidChangeScrollTop () { 114 | const {element} = this.editor 115 | await element.component.getNextUpdatePromise() 116 | this.editorProxy.didScroll() 117 | this.emitter.emit('did-scroll') 118 | } 119 | 120 | async editorDidChangeScrollLeft () { 121 | const {element} = this.editor 122 | await element.component.getNextUpdatePromise() 123 | this.editorProxy.didScroll() 124 | this.emitter.emit('did-scroll') 125 | } 126 | 127 | async editorDidResize () { 128 | const {element} = this.editor 129 | await element.component.getNextUpdatePromise() 130 | this.emitter.emit('did-resize') 131 | } 132 | 133 | onDidDispose (callback) { 134 | return this.emitter.on('did-dispose', callback) 135 | } 136 | 137 | onDidScroll (callback) { 138 | return this.emitter.on('did-scroll', callback) 139 | } 140 | 141 | onDidResize (callback) { 142 | return this.emitter.on('did-resize', callback) 143 | } 144 | 145 | updateSelectionsForSiteId (siteId, selections) { 146 | let markerLayer = this.markerLayersBySiteId.get(siteId) 147 | if (!markerLayer) { 148 | markerLayer = this.editor.addMarkerLayer() 149 | this.editor.decorateMarkerLayer(markerLayer, {type: 'cursor', class: cursorClassForSiteId(siteId, {blink: false})}) 150 | this.editor.decorateMarkerLayer(markerLayer, {type: 'highlight', class: 'selection'}) 151 | this.markerLayersBySiteId.set(siteId, markerLayer) 152 | } 153 | 154 | let markersById = this.markersByLayerAndId.get(markerLayer) 155 | if (!markersById) { 156 | markersById = new Map() 157 | this.markersByLayerAndId.set(markerLayer, markersById) 158 | } 159 | 160 | let maxMarkerId 161 | for (let markerId in selections) { 162 | const markerUpdate = selections[markerId] 163 | markerId = parseInt(markerId) 164 | let marker = markersById.get(markerId) 165 | 166 | if (markerUpdate) { 167 | maxMarkerId = maxMarkerId ? Math.max(maxMarkerId, markerId) : markerId 168 | 169 | const {start, end} = markerUpdate.range 170 | const newRange = Range(start, end) 171 | if (marker) { 172 | marker.setBufferRange(newRange, {reversed: markerUpdate.reversed}) 173 | } else { 174 | marker = markerLayer.markBufferRange(newRange, {invalidate: 'never', reversed: markerUpdate.reversed}) 175 | marker.bufferMarker.onDidChange(({textChanged}) => { 176 | if (textChanged && marker.getBufferRange().isEmpty()) { 177 | marker.clearTail() 178 | } 179 | }) 180 | 181 | markersById.set(markerId, marker) 182 | } 183 | 184 | if (newRange.isEmpty()) marker.clearTail() 185 | } else { 186 | marker.destroy() 187 | markersById.delete(markerId) 188 | } 189 | } 190 | } 191 | 192 | isScrollNeededToViewPosition (position) { 193 | const isPositionVisible = this.getDirectionFromViewportToPosition(position) === 'inside' 194 | const isEditorAttachedToDOM = document.body.contains(this.editor.element) 195 | return isEditorAttachedToDOM && !isPositionVisible 196 | } 197 | 198 | updateTether (state, position) { 199 | const localCursorDecorationProperties = {type: 'cursor'} 200 | 201 | if (state === FollowState.RETRACTED) { 202 | this.editor.destroyFoldsIntersectingBufferRange(Range(position, position)) 203 | this.batchMarkerUpdates(() => this.editor.setCursorBufferPosition(position)) 204 | 205 | localCursorDecorationProperties.style = {opacity: 0} 206 | } else { 207 | localCursorDecorationProperties.class = cursorClassForSiteId(this.editorProxy.siteId) 208 | } 209 | 210 | this.localCursorLayerDecoration.setProperties(localCursorDecorationProperties) 211 | } 212 | 213 | getDirectionFromViewportToPosition (bufferPosition) { 214 | const {element} = this.editor 215 | if (!document.contains(element)) return 216 | 217 | const {row, column} = this.editor.screenPositionForBufferPosition(bufferPosition) 218 | const top = element.component.pixelPositionAfterBlocksForRow(row) 219 | const left = column * this.editor.getDefaultCharWidth() 220 | 221 | if (top < element.getScrollTop()) { 222 | return 'upward' 223 | } else if (top >= element.getScrollBottom()) { 224 | return 'downward' 225 | } else if (left < element.getScrollLeft()) { 226 | return 'leftward' 227 | } else if (left >= element.getScrollRight()) { 228 | return 'rightward' 229 | } else { 230 | return 'inside' 231 | } 232 | } 233 | 234 | clearSelectionsForSiteId (siteId) { 235 | const markerLayer = this.markerLayersBySiteId.get(siteId) 236 | if (markerLayer != null) markerLayer.destroy() 237 | this.markerLayersBySiteId.delete(siteId) 238 | this.markersByLayerAndId.delete(markerLayer) 239 | } 240 | 241 | relayLocalSelections () { 242 | const selectionUpdates = {} 243 | const selectionMarkers = this.selectionsMarkerLayer.getMarkers() 244 | for (let i = 0; i < selectionMarkers.length; i++) { 245 | const marker = selectionMarkers[i] 246 | selectionUpdates[marker.id] = getSelectionState(marker) 247 | } 248 | this.editorProxy.updateSelections(selectionUpdates, {initialUpdate: true}) 249 | } 250 | 251 | batchMarkerUpdates (fn) { 252 | this.batchedMarkerUpdates = {} 253 | this.isBatchingMarkerUpdates = true 254 | fn() 255 | this.isBatchingMarkerUpdates = false 256 | this.editorProxy.updateSelections(this.batchedMarkerUpdates) 257 | this.batchedMarkerUpdates = null 258 | } 259 | 260 | updateSelections (update) { 261 | if (this.isBatchingMarkerUpdates) { 262 | Object.assign(this.batchedMarkerUpdates, update) 263 | } else { 264 | this.editorProxy.updateSelections(update) 265 | } 266 | } 267 | 268 | toggleFollowingForSiteId (siteId) { 269 | if (siteId === this.editorProxy.getFollowedSiteId()) { 270 | this.editorProxy.unfollow() 271 | } else { 272 | this.editorProxy.follow(siteId) 273 | } 274 | } 275 | } 276 | 277 | function getSelectionState (marker) { 278 | return { 279 | range: marker.getRange(), 280 | exclusive: marker.isExclusive(), 281 | reversed: marker.isReversed() 282 | } 283 | } 284 | 285 | function cursorClassForSiteId (siteId, {blink} = {}) { 286 | let className = 'ParticipantCursor--site-' + siteId 287 | if (blink === false) className += ' non-blinking' 288 | return className 289 | } 290 | 291 | function subscribeToResizeEvents (element, callback) { 292 | const resizeObserver = new ResizeObserver(callback) 293 | resizeObserver.observe(element) 294 | return new Disposable(() => resizeObserver.disconnect()) 295 | } 296 | -------------------------------------------------------------------------------- /lib/get-avatar-url.js: -------------------------------------------------------------------------------- 1 | module.exports = function (login, size) { 2 | let url = `https://avatars.githubusercontent.com/${login}` 3 | if (size) url += `?s=${size}` 4 | 5 | return url 6 | } 7 | -------------------------------------------------------------------------------- /lib/get-path-with-native-separators.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const WINDOWS_PATH_SEP_SEARCH_PATTERN = /\\/g 4 | const POSIX_PATH_SEP_SEARCH_PATTERN = /\//g 5 | 6 | module.exports = 7 | function getPathWithNativeSeparators (uri, targetPathSeparator = path.sep) { 8 | const PATH_SEP_SEARCH_PATTERN = (targetPathSeparator === '/') ? WINDOWS_PATH_SEP_SEARCH_PATTERN : POSIX_PATH_SEP_SEARCH_PATTERN 9 | return uri.replace(PATH_SEP_SEARCH_PATTERN, targetPathSeparator) 10 | } 11 | -------------------------------------------------------------------------------- /lib/guest-portal-binding-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const ParticipantsComponent = require('./participants-component') 4 | 5 | module.exports = 6 | class GuestPortalBindingComponent { 7 | constructor (props) { 8 | this.props = props 9 | this.subscribeToPortalBindingChanges(this.props.portalBinding) 10 | etch.initialize(this) 11 | } 12 | 13 | destroy () { 14 | if (this.subscriptions) this.subscriptions.dispose() 15 | return etch.destroy(this) 16 | } 17 | 18 | update (props) { 19 | if (props.portalBinding !== this.props.portalBinding) { 20 | this.subscribeToPortalBindingChanges(props.portalBinding) 21 | } 22 | 23 | this.props = props 24 | return etch.update(this) 25 | } 26 | 27 | subscribeToPortalBindingChanges (portalBinding) { 28 | if (this.subscriptions) this.subscriptions.dispose() 29 | if (portalBinding) { 30 | this.subscriptions = portalBinding.onDidChange(() => etch.update(this)) 31 | } 32 | } 33 | 34 | render () { 35 | return $.div({className: 'GuestPortalComponent'}, 36 | $(ParticipantsComponent, {portalBinding: this.props.portalBinding}), 37 | $.button({className: 'btn btn-xs GuestPortalComponent-leave', onClick: this.leavePortal}, 'Leave') 38 | ) 39 | } 40 | 41 | leavePortal () { 42 | this.props.portalBinding.leave() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/guest-portal-binding.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Emitter, TextEditor, TextBuffer} = require('atom') 2 | const {Errors, FollowState} = require('@atom/teletype-client') 3 | const BufferBinding = require('./buffer-binding') 4 | const EditorBinding = require('./editor-binding') 5 | const SitePositionsComponent = require('./site-positions-component') 6 | const getPathWithNativeSeparators = require('./get-path-with-native-separators') 7 | const {getEditorURI} = require('./uri-helpers') 8 | const NOOP = () => {} 9 | 10 | module.exports = 11 | class GuestPortalBinding { 12 | constructor ({client, portalId, workspace, notificationManager, didDispose}) { 13 | this.client = client 14 | this.portalId = portalId 15 | this.workspace = workspace 16 | this.notificationManager = notificationManager 17 | this.emitDidDispose = didDispose || NOOP 18 | this.lastActivePaneItem = null 19 | this.editorBindingsByEditorProxyId = new Map() 20 | this.bufferBindingsByBufferProxyId = new Map() 21 | this.editorProxiesByEditor = new WeakMap() 22 | this.editorProxiesMetadataById = new Map() 23 | this.emitter = new Emitter() 24 | this.subscriptions = new CompositeDisposable() 25 | this.lastEditorProxyChangePromise = Promise.resolve() 26 | this.shouldRelayActiveEditorChanges = true 27 | } 28 | 29 | async initialize () { 30 | try { 31 | this.portal = await this.client.joinPortal(this.portalId) 32 | if (!this.portal) return false 33 | 34 | this.sitePositionsComponent = new SitePositionsComponent({portal: this.portal, workspace: this.workspace}) 35 | this.subscriptions.add(this.workspace.onDidChangeActivePaneItem(this.didChangeActivePaneItem.bind(this))) 36 | 37 | await this.portal.setDelegate(this) 38 | 39 | return true 40 | } catch (error) { 41 | this.didFailToJoin(error) 42 | return false 43 | } 44 | } 45 | 46 | dispose () { 47 | this.subscriptions.dispose() 48 | this.sitePositionsComponent.destroy() 49 | 50 | this.emitDidDispose() 51 | } 52 | 53 | siteDidJoin (siteId) { 54 | const {login: hostLogin} = this.portal.getSiteIdentity(1) 55 | const {login: siteLogin} = this.portal.getSiteIdentity(siteId) 56 | this.notificationManager.addInfo(`@${siteLogin} has joined @${hostLogin}'s portal`) 57 | this.emitter.emit('did-change') 58 | } 59 | 60 | siteDidLeave (siteId) { 61 | const {login: hostLogin} = this.portal.getSiteIdentity(1) 62 | const {login: siteLogin} = this.portal.getSiteIdentity(siteId) 63 | this.notificationManager.addInfo(`@${siteLogin} has left @${hostLogin}'s portal`) 64 | this.emitter.emit('did-change') 65 | } 66 | 67 | didChangeEditorProxies () {} 68 | 69 | getRemoteEditors () { 70 | const hostIdentity = this.portal.getSiteIdentity(1) 71 | const bufferProxyIds = new Set() 72 | const remoteEditors = [] 73 | const editorProxiesMetadata = this.portal.getEditorProxiesMetadata() 74 | 75 | for (let i = 0; i < editorProxiesMetadata.length; i++) { 76 | const {id, bufferProxyId, bufferProxyURI} = editorProxiesMetadata[i] 77 | if (bufferProxyIds.has(bufferProxyId)) continue 78 | 79 | remoteEditors.push({ 80 | hostGitHubUsername: hostIdentity.login, 81 | uri: getEditorURI(this.portal.id, id), 82 | path: getPathWithNativeSeparators(bufferProxyURI) 83 | }) 84 | bufferProxyIds.add(bufferProxyId) 85 | } 86 | 87 | return remoteEditors 88 | } 89 | 90 | async getRemoteEditor (editorProxyId) { 91 | const editorProxy = await this.portal.findOrFetchEditorProxy(editorProxyId) 92 | if (editorProxy) { 93 | return this.findOrCreateEditorForEditorProxy(editorProxy) 94 | } else { 95 | return null 96 | } 97 | } 98 | 99 | updateActivePositions (positionsBySiteId) { 100 | this.sitePositionsComponent.update({positionsBySiteId}) 101 | } 102 | 103 | updateTether (followState, editorProxy, position) { 104 | if (editorProxy) { 105 | this.lastEditorProxyChangePromise = this.lastEditorProxyChangePromise.then(() => 106 | this._updateTether(followState, editorProxy, position) 107 | ) 108 | } 109 | 110 | return this.lastEditorProxyChangePromise 111 | } 112 | 113 | // Private 114 | async _updateTether (followState, editorProxy, position) { 115 | if (followState === FollowState.RETRACTED) { 116 | const editor = this.findOrCreateEditorForEditorProxy(editorProxy) 117 | this.shouldRelayActiveEditorChanges = false 118 | await this.openPaneItem(editor) 119 | this.shouldRelayActiveEditorChanges = true 120 | } else { 121 | this.editorBindingsByEditorProxyId.forEach((b) => b.updateTether(followState)) 122 | } 123 | 124 | const editorBinding = this.editorBindingsByEditorProxyId.get(editorProxy.id) 125 | if (editorBinding && position) { 126 | editorBinding.updateTether(followState, position) 127 | } 128 | } 129 | 130 | // Private 131 | findOrCreateEditorForEditorProxy (editorProxy) { 132 | let editor 133 | let editorBinding = this.editorBindingsByEditorProxyId.get(editorProxy.id) 134 | if (editorBinding) { 135 | editor = editorBinding.editor 136 | } else { 137 | const {bufferProxy} = editorProxy 138 | const buffer = this.findOrCreateBufferForBufferProxy(bufferProxy) 139 | editor = new TextEditor({buffer, autoHeight: false}) 140 | editorBinding = new EditorBinding({ 141 | editor, 142 | portal: this.portal, 143 | isHost: false 144 | }) 145 | editorBinding.setEditorProxy(editorProxy) 146 | editorProxy.setDelegate(editorBinding) 147 | 148 | this.editorBindingsByEditorProxyId.set(editorProxy.id, editorBinding) 149 | this.editorProxiesByEditor.set(editor, editorProxy) 150 | 151 | const didDestroyEditorSubscription = editor.onDidDestroy(() => editorBinding.dispose()) 152 | editorBinding.onDidDispose(() => { 153 | didDestroyEditorSubscription.dispose() 154 | 155 | const isRetracted = this.portal.resolveFollowState() === FollowState.RETRACTED 156 | this.shouldRelayActiveEditorChanges = !isRetracted 157 | editor.destroy() 158 | this.shouldRelayActiveEditorChanges = true 159 | 160 | this.editorProxiesByEditor.delete(editor) 161 | this.editorBindingsByEditorProxyId.delete(editorProxy.id) 162 | }) 163 | } 164 | return editor 165 | } 166 | 167 | // Private 168 | findOrCreateBufferForBufferProxy (bufferProxy) { 169 | let buffer 170 | let bufferBinding = this.bufferBindingsByBufferProxyId.get(bufferProxy.id) 171 | if (bufferBinding) { 172 | buffer = bufferBinding.buffer 173 | } else { 174 | buffer = new TextBuffer() 175 | bufferBinding = new BufferBinding({ 176 | buffer, 177 | isHost: false, 178 | didDispose: () => this.bufferBindingsByBufferProxyId.delete(bufferProxy.id) 179 | }) 180 | bufferBinding.setBufferProxy(bufferProxy) 181 | bufferProxy.setDelegate(bufferBinding) 182 | this.bufferBindingsByBufferProxyId.set(bufferProxy.id, bufferBinding) 183 | } 184 | return buffer 185 | } 186 | 187 | activate () { 188 | const paneItem = this.lastActivePaneItem 189 | const pane = this.workspace.paneForItem(paneItem) 190 | if (pane && paneItem) { 191 | pane.activateItem(paneItem) 192 | pane.activate() 193 | } 194 | } 195 | 196 | didFailToJoin (error) { 197 | let message, description 198 | if (error instanceof Errors.PortalNotFoundError) { 199 | message = 'Portal not found' 200 | description = 201 | 'The portal you were trying to join does not exist. ' + 202 | 'Please ask your host to provide you with their current portal URL.' 203 | } else { 204 | message = 'Failed to join portal' 205 | description = 206 | `Attempting to join portal failed with error: ${error.message}\n\n` + 207 | 'Please wait a few moments and try again.' 208 | } 209 | this.notificationManager.addError(message, { 210 | description, 211 | dismissable: true 212 | }) 213 | } 214 | 215 | hostDidClosePortal () { 216 | this.notificationManager.addInfo('Portal closed', { 217 | description: 'Your host stopped sharing their editor.', 218 | dismissable: true 219 | }) 220 | } 221 | 222 | hostDidLoseConnection () { 223 | this.notificationManager.addInfo('Portal closed', { 224 | description: ( 225 | 'We haven\'t heard from the host in a while.\n' + 226 | 'Once your host is back online, they can share a new portal with you to resume collaborating.' 227 | ), 228 | dismissable: true 229 | }) 230 | } 231 | 232 | leave () { 233 | if (this.portal) this.portal.dispose() 234 | } 235 | 236 | async openPaneItem (newActivePaneItem) { 237 | this.newActivePaneItem = newActivePaneItem 238 | await this.workspace.open(newActivePaneItem, {searchAllPanes: true}) 239 | this.lastActivePaneItem = this.newActivePaneItem 240 | this.newActivePaneItem = null 241 | } 242 | 243 | didChangeActivePaneItem (paneItem) { 244 | const editorProxy = this.editorProxiesByEditor.get(paneItem) 245 | 246 | if (editorProxy) { 247 | this.sitePositionsComponent.show(paneItem.element) 248 | } else { 249 | this.sitePositionsComponent.hide() 250 | } 251 | 252 | if (this.shouldRelayActiveEditorChanges) { 253 | this.portal.activateEditorProxy(editorProxy) 254 | } 255 | } 256 | 257 | hasPaneItem (paneItem) { 258 | return this.editorProxiesByEditor.has(paneItem) 259 | } 260 | 261 | getActivePaneItem () { 262 | return this.newActivePaneItem || this.workspace.getActivePaneItem() 263 | } 264 | 265 | onDidChange (callback) { 266 | return this.emitter.on('did-change', callback) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /lib/host-portal-binding-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const ParticipantsComponent = require('./participants-component') 4 | 5 | module.exports = 6 | class HostPortalBindingComponent { 7 | constructor (props) { 8 | this.props = props 9 | this.subscribeToPortalBindingChanges(this.props.portalBinding) 10 | etch.initialize(this) 11 | } 12 | 13 | destroy () { 14 | if (this.subscriptions) this.subscriptions.dispose() 15 | return etch.destroy(this) 16 | } 17 | 18 | update (props) { 19 | if (props.portalBinding !== this.props.portalBinding) { 20 | this.subscribeToPortalBindingChanges(props.portalBinding) 21 | } 22 | 23 | Object.assign(this.props, props) 24 | return etch.update(this) 25 | } 26 | 27 | subscribeToPortalBindingChanges (portalBinding) { 28 | if (this.subscriptions) this.subscriptions.dispose() 29 | if (portalBinding) { 30 | this.subscriptions = portalBinding.onDidChange(() => etch.update(this)) 31 | } 32 | } 33 | 34 | render () { 35 | return ( 36 | $.div({className: 'HostPortalComponent'}, 37 | this.renderConnectionInfo(), 38 | $.div({className: 'HostPortalComponent-status'}, 39 | $(ParticipantsComponent, { 40 | portalBinding: this.props.portalBinding, 41 | localUserIdentity: this.props.localUserIdentity 42 | }), 43 | $.div({className: 'HostPortalComponent-share-toggle'}, 44 | $.label(null, 45 | 'Share ', 46 | $.input({ 47 | ref: 'toggleShareCheckbox', 48 | className: 'input-toggle', 49 | type: 'checkbox', 50 | onClick: this.toggleShare, 51 | checked: this.isSharing() || this.props.creatingPortal 52 | }) 53 | ) 54 | ) 55 | ) 56 | ) 57 | ) 58 | } 59 | 60 | renderConnectionInfo () { 61 | const {creatingPortal, showCopiedConfirmation} = this.props 62 | const statusClassName = creatingPortal ? 'creating-portal' : '' 63 | if (creatingPortal || this.isSharing()) { 64 | const copyButtonText = showCopiedConfirmation ? 'Copied' : 'Copy' 65 | return $.div({className: 'HostPortalComponent-connection-info'}, 66 | creatingPortal ? this.renderCreatingPortalSpinner() : null, 67 | $.div({className: 'HostPortalComponent-connection-info-heading ' + statusClassName}, 68 | $.h1(null, 'Invite collaborators to join your portal with this URL') 69 | ), 70 | $.div({className: 'HostPortalComponent-connection-info-portal-url ' + statusClassName}, 71 | $.input({className: 'input-text host-id-input', type: 'text', disabled: true, value: this.getPortalURI()}), 72 | $.button({className: 'btn btn-xs', onClick: this.copyPortalURLToClipboard}, copyButtonText) 73 | ) 74 | ) 75 | } else { 76 | return null 77 | } 78 | } 79 | 80 | renderCreatingPortalSpinner () { 81 | return $.span({ref: 'creatingPortalSpinner', className: 'HostPortalComponent-connection-info-spinner loading loading-spinner-tiny'}) 82 | } 83 | 84 | async toggleShare () { 85 | if (this.props.portalBinding) { 86 | this.props.portalBinding.close() 87 | } else { 88 | await this.update({creatingPortal: true}) 89 | await this.props.portalBindingManager.createHostPortalBinding() 90 | await this.update({creatingPortal: false}) 91 | } 92 | } 93 | 94 | copyPortalURLToClipboard () { 95 | const {clipboard} = this.props 96 | clipboard.write(this.getPortalURI()) 97 | 98 | if (this.copiedConfirmationResetTimeoutId) { 99 | clearTimeout(this.copiedConfirmationResetTimeoutId) 100 | } 101 | 102 | this.props.showCopiedConfirmation = true 103 | etch.update(this) 104 | this.copiedConfirmationResetTimeoutId = setTimeout(() => { 105 | this.props.showCopiedConfirmation = false 106 | etch.update(this) 107 | this.copiedConfirmationResetTimeoutId = null 108 | }, 2000) 109 | } 110 | 111 | isSharing () { 112 | return this.props.portalBinding != null 113 | } 114 | 115 | getPortalURI () { 116 | if (this.props.portalBinding) { 117 | return this.props.portalBinding.uri 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lib/host-portal-binding.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Emitter} = require('atom') 2 | const {FollowState} = require('@atom/teletype-client') 3 | const BufferBinding = require('./buffer-binding') 4 | const EditorBinding = require('./editor-binding') 5 | const SitePositionsComponent = require('./site-positions-component') 6 | const {getPortalURI} = require('./uri-helpers') 7 | 8 | module.exports = 9 | class HostPortalBinding { 10 | constructor ({client, workspace, notificationManager, didDispose}) { 11 | this.client = client 12 | this.workspace = workspace 13 | this.notificationManager = notificationManager 14 | this.editorBindingsByEditor = new WeakMap() 15 | this.editorBindingsByEditorProxy = new Map() 16 | this.bufferBindingsByBuffer = new WeakMap() 17 | this.disposables = new CompositeDisposable() 18 | this.emitter = new Emitter() 19 | this.lastUpdateTetherPromise = Promise.resolve() 20 | this.didDispose = didDispose 21 | } 22 | 23 | async initialize () { 24 | try { 25 | this.portal = await this.client.createPortal() 26 | if (!this.portal) return false 27 | 28 | this.uri = getPortalURI(this.portal.id) 29 | this.sitePositionsComponent = new SitePositionsComponent({portal: this.portal, workspace: this.workspace}) 30 | 31 | this.portal.setDelegate(this) 32 | this.disposables.add( 33 | this.workspace.observeTextEditors(this.didAddTextEditor.bind(this)), 34 | this.workspace.observeActiveTextEditor(this.didChangeActiveTextEditor.bind(this)) 35 | ) 36 | 37 | this.workspace.getElement().classList.add('teletype-Host') 38 | return true 39 | } catch (error) { 40 | this.notificationManager.addError('Failed to share portal', { 41 | description: `Attempting to share a portal failed with error: ${error.message}`, 42 | dismissable: true 43 | }) 44 | return false 45 | } 46 | } 47 | 48 | dispose () { 49 | this.workspace.getElement().classList.remove('teletype-Host') 50 | this.sitePositionsComponent.destroy() 51 | this.disposables.dispose() 52 | this.didDispose() 53 | } 54 | 55 | close () { 56 | this.portal.dispose() 57 | } 58 | 59 | siteDidJoin (siteId) { 60 | const {login} = this.portal.getSiteIdentity(siteId) 61 | this.notificationManager.addInfo(`@${login} has joined your portal`) 62 | this.emitter.emit('did-change') 63 | } 64 | 65 | siteDidLeave (siteId) { 66 | const {login} = this.portal.getSiteIdentity(siteId) 67 | this.notificationManager.addInfo(`@${login} has left your portal`) 68 | this.emitter.emit('did-change') 69 | } 70 | 71 | onDidChange (callback) { 72 | return this.emitter.on('did-change', callback) 73 | } 74 | 75 | didChangeActiveTextEditor (editor) { 76 | if (editor && !editor.isRemote) { 77 | const editorProxy = this.findOrCreateEditorProxyForEditor(editor) 78 | this.portal.activateEditorProxy(editorProxy) 79 | this.sitePositionsComponent.show(editor.element) 80 | } else { 81 | this.portal.activateEditorProxy(null) 82 | this.sitePositionsComponent.hide() 83 | } 84 | } 85 | 86 | updateActivePositions (positionsBySiteId) { 87 | this.sitePositionsComponent.update({positionsBySiteId}) 88 | } 89 | 90 | updateTether (followState, editorProxy, position) { 91 | if (editorProxy) { 92 | this.lastUpdateTetherPromise = this.lastUpdateTetherPromise.then(() => 93 | this._updateTether(followState, editorProxy, position) 94 | ) 95 | } 96 | 97 | return this.lastUpdateTetherPromise 98 | } 99 | 100 | // Private 101 | async _updateTether (followState, editorProxy, position) { 102 | const editorBinding = this.editorBindingsByEditorProxy.get(editorProxy) 103 | 104 | if (followState === FollowState.RETRACTED) { 105 | await this.workspace.open(editorBinding.editor, {searchAllPanes: true}) 106 | if (position) editorBinding.updateTether(followState, position) 107 | } else { 108 | this.editorBindingsByEditorProxy.forEach((b) => b.updateTether(followState)) 109 | } 110 | } 111 | 112 | didAddTextEditor (editor) { 113 | if (!editor.isRemote) this.findOrCreateEditorProxyForEditor(editor) 114 | } 115 | 116 | findOrCreateEditorProxyForEditor (editor) { 117 | let editorBinding = this.editorBindingsByEditor.get(editor) 118 | if (editorBinding) { 119 | return editorBinding.editorProxy 120 | } else { 121 | const bufferProxy = this.findOrCreateBufferProxyForBuffer(editor.getBuffer()) 122 | const editorProxy = this.portal.createEditorProxy({bufferProxy}) 123 | editorBinding = new EditorBinding({editor, portal: this.portal, isHost: true}) 124 | editorBinding.setEditorProxy(editorProxy) 125 | editorProxy.setDelegate(editorBinding) 126 | 127 | this.editorBindingsByEditor.set(editor, editorBinding) 128 | this.editorBindingsByEditorProxy.set(editorProxy, editorBinding) 129 | 130 | const didDestroyEditorSubscription = editor.onDidDestroy(() => editorProxy.dispose()) 131 | editorBinding.onDidDispose(() => { 132 | didDestroyEditorSubscription.dispose() 133 | this.editorBindingsByEditorProxy.delete(editorProxy) 134 | }) 135 | 136 | return editorProxy 137 | } 138 | } 139 | 140 | findOrCreateBufferProxyForBuffer (buffer) { 141 | let bufferBinding = this.bufferBindingsByBuffer.get(buffer) 142 | if (bufferBinding) { 143 | return bufferBinding.bufferProxy 144 | } else { 145 | bufferBinding = new BufferBinding({buffer, isHost: true}) 146 | const bufferProxy = this.portal.createBufferProxy({ 147 | uri: bufferBinding.getBufferProxyURI(), 148 | history: buffer.getHistory() 149 | }) 150 | bufferBinding.setBufferProxy(bufferProxy) 151 | bufferProxy.setDelegate(bufferBinding) 152 | 153 | this.bufferBindingsByBuffer.set(buffer, bufferBinding) 154 | 155 | return bufferProxy 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /lib/join-portal-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const {TextEditor} = require('atom') 4 | const {findPortalId} = require('./portal-id-helpers') 5 | 6 | module.exports = 7 | class JoinPortalComponent { 8 | constructor (props) { 9 | this.props = props 10 | etch.initialize(this) 11 | this.disposables = this.props.commandRegistry.add(this.element, { 12 | 'core:confirm': this.joinPortal.bind(this), 13 | 'core:cancel': this.hidePrompt.bind(this) 14 | }) 15 | } 16 | 17 | destroy () { 18 | this.disposables.dispose() 19 | return etch.destroy(this) 20 | } 21 | 22 | update (props) { 23 | Object.assign(this.props, props) 24 | return etch.update(this) 25 | } 26 | 27 | readAfterUpdate () { 28 | const previousPortalIdEditor = this.portalIdEditor 29 | this.portalIdEditor = this.refs.portalIdEditor 30 | 31 | if (!previousPortalIdEditor && this.portalIdEditor) { 32 | this.portalIdEditor.onDidChange(() => { 33 | const portalId = this.refs.portalIdEditor.getText().trim() 34 | this.refs.joinButton.disabled = !findPortalId(portalId) 35 | }) 36 | } 37 | } 38 | 39 | writeAfterUpdate () { 40 | // This fixes a visual glitch due to the editor component using stale font 41 | // measurements when rendered for the first time. 42 | if (this.refs.portalIdEditor) { 43 | const {component} = this.refs.portalIdEditor 44 | component.didUpdateStyles() 45 | component.updateSync() 46 | } 47 | } 48 | 49 | render () { 50 | const {joining, promptVisible} = this.props 51 | if (joining) { 52 | return $.div({className: 'JoinPortalComponent--no-prompt'}, 53 | $.span({ref: 'joiningSpinner', className: 'loading loading-spinner-tiny inline-block'}) 54 | ) 55 | } else if (promptVisible) { 56 | return $.div({className: 'JoinPortalComponent--prompt', tabIndex: -1}, 57 | $(TextEditor, {ref: 'portalIdEditor', mini: true, placeholderText: 'Enter a portal URL...'}), 58 | $.button({ref: 'joinButton', type: 'button', disabled: true, className: 'btn btn-xs', onClick: this.joinPortal}, 'Join') 59 | ) 60 | } else { 61 | return $.div({className: 'JoinPortalComponent--no-prompt'}, 62 | $.label({ref: 'joinPortalLabel', onClick: this.showPrompt}, 'Join a portal') 63 | ) 64 | } 65 | } 66 | 67 | async showPrompt () { 68 | await this.update({promptVisible: true}) 69 | 70 | let clipboardText = this.props.clipboard.read() 71 | if (clipboardText) clipboardText = clipboardText.trim() 72 | if (findPortalId(clipboardText)) { 73 | this.refs.portalIdEditor.setText(clipboardText) 74 | } 75 | this.refs.portalIdEditor.element.focus() 76 | } 77 | 78 | async hidePrompt () { 79 | await this.update({promptVisible: false}) 80 | } 81 | 82 | async joinPortal () { 83 | const {portalBindingManager} = this.props 84 | const portalId = findPortalId(this.refs.portalIdEditor.getText().trim()) 85 | 86 | if (!portalId) { 87 | this.props.notificationManager.addError('Invalid format', { 88 | description: 'This doesn\'t look like a valid portal identifier. Please ask your host to provide you with their current portal URL and try again.', 89 | dismissable: true 90 | }) 91 | return 92 | } 93 | 94 | await this.update({joining: true}) 95 | if (await portalBindingManager.createGuestPortalBinding(portalId)) { 96 | await this.update({joining: false, promptVisible: false}) 97 | } else { 98 | await this.update({joining: false}) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/join-via-external-app-dialog.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | 4 | module.exports = 5 | class JoinViaExternalAppDialog { 6 | constructor ({config, commandRegistry, workspace}) { 7 | this.commandRegistry = commandRegistry 8 | this.workspace = workspace 9 | this.confirm = this.confirm.bind(this) 10 | this.cancel = this.cancel.bind(this) 11 | this.handleBlur = this.handleBlur.bind(this) 12 | this.props = {uri: ''} 13 | etch.initialize(this) 14 | this.disposables = this.commandRegistry.add(this.element, { 15 | 'core:confirm': this.confirm, 16 | 'core:cancel': this.cancel 17 | }) 18 | } 19 | 20 | destroy () { 21 | if (this.panel) this.panel.destroy() 22 | 23 | this.disposables.dispose() 24 | etch.destroy(this) 25 | } 26 | 27 | async show (uri) { 28 | await this.update({uri}) 29 | 30 | // This dialog could be opened before Atom's workaround for window focus is 31 | // triggered (see https://git.io/vxWDa), so we delay focusing it to prevent 32 | // such workaround from stealing focus from the dialog. 33 | await timeout(5) 34 | 35 | // We explicitly add the modal as hidden because of a bug in the auto-focus 36 | // feature that prevents it from working correctly when using visible: true. 37 | this.panel = this.workspace.addModalPanel({item: this, visible: false, autoFocus: true}) 38 | this.panel.show() 39 | this.element.focus() 40 | this.element.addEventListener('blur', this.handleBlur) 41 | 42 | return new Promise((resolve) => { 43 | this.resolveWithExitStatus = resolve 44 | this.panel.onDidDestroy(() => { 45 | this.panel = null 46 | this.element.removeEventListener('blur', this.handleBlur) 47 | }) 48 | }) 49 | } 50 | 51 | confirm () { 52 | if (this.refs.joinWithoutAskingCheckbox.checked) { 53 | this.confirmAlways() 54 | } else { 55 | this.confirmOnce() 56 | } 57 | } 58 | 59 | confirmOnce () { 60 | this.resolveWithExitStatus(this.constructor.EXIT_STATUS.CONFIRM_ONCE) 61 | this.panel.destroy() 62 | } 63 | 64 | confirmAlways () { 65 | this.resolveWithExitStatus(this.constructor.EXIT_STATUS.CONFIRM_ALWAYS) 66 | this.panel.destroy() 67 | } 68 | 69 | cancel () { 70 | this.resolveWithExitStatus(this.constructor.EXIT_STATUS.CANCEL) 71 | this.panel.destroy() 72 | } 73 | 74 | render () { 75 | return $.div({className: 'JoinViaExternalAppDialog', tabIndex: -1}, 76 | $.div(null, 77 | $.h1(null, 'Join this portal?'), 78 | $.a({className: 'JoinViaExternalAppDialog-cancel icon icon-x', onClick: this.cancel}) 79 | ), 80 | $.p({className: 'JoinViaExternalAppDialog-uri'}, this.props.uri), 81 | $.p(null, 'By joining this portal, the other collaborators will see your GitHub username, your avatar, and any edits that you perform inside the portal.'), 82 | $.footer({className: 'JoinViaExternalAppDialog-footer'}, 83 | $.label({className: 'input-label'}, 84 | $.input({ 85 | ref: 'joinWithoutAskingCheckbox', 86 | className: 'input-checkbox', 87 | type: 'checkbox' 88 | }), 89 | $.span(null, 'Always join without asking. I only open URLs from people I trust.') 90 | ), 91 | $.button( 92 | {className: 'btn btn-lg btn-primary', onClick: this.confirm}, 93 | 'Join portal' 94 | ) 95 | ) 96 | ) 97 | } 98 | 99 | update (props) { 100 | this.props = props 101 | return etch.update(this) 102 | } 103 | 104 | writeAfterUpdate () { 105 | this.refs.joinWithoutAskingCheckbox.checked = false 106 | } 107 | 108 | handleBlur (event) { 109 | if (document.hasFocus() && !this.element.contains(event.relatedTarget)) { 110 | this.cancel() 111 | } 112 | } 113 | } 114 | 115 | module.exports.EXIT_STATUS = { 116 | CONFIRM_ALWAYS: 0, 117 | CONFIRM_ONCE: 1, 118 | CANCEL: 2 119 | } 120 | 121 | function timeout (ms) { 122 | return new Promise((resolve) => setTimeout(resolve, ms)) 123 | } 124 | -------------------------------------------------------------------------------- /lib/package-initialization-error-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const {URL} = require('url') 3 | const $ = etch.dom 4 | 5 | module.exports = 6 | class PackageInitializationErrorComponent { 7 | constructor (props) { 8 | this.props = props 9 | etch.initialize(this) 10 | } 11 | 12 | update (props) { 13 | Object.assign(this.props, props) 14 | return etch.update(this) 15 | } 16 | 17 | render () { 18 | return $.div({className: 'PackageInitializationErrorComponent'}, 19 | $.h3(null, 'Teletype initialization failed'), 20 | $.p(null, 'Make sure your internet connection is working and restart the package.'), 21 | $.div(null, 22 | $.button( 23 | { 24 | ref: 'reloadButton', 25 | type: 'button', 26 | className: 'btn btn-primary inline-block-tight', 27 | onClick: this.restartTeletype 28 | }, 29 | 'Restart Teletype' 30 | ) 31 | ), 32 | $.p(null, 33 | 'If the problem persists, visit ', 34 | $.a({href: this.getIssueURL(), className: 'text-info'}, 'atom/teletype'), 35 | ' and open an issue.' 36 | ) 37 | ) 38 | } 39 | 40 | getIssueURL () { 41 | const {initializationError} = this.props 42 | 43 | const url = new URL('https://github.com/atom/teletype/issues/new') 44 | url.searchParams.append('title', 'Package Initialization Error') 45 | url.searchParams.append('body', 46 | '### Diagnostics\n\n' + 47 | '```\n' + 48 | initializationError.diagnosticMessage + '\n\n' + 49 | '```\n' + 50 | '### Versions\n\n' + 51 | `**Teletype version**: v${getTeletypeVersion()}\n` + 52 | `**Atom version**: ${this.props.getAtomVersion()}\n` + 53 | `**Platform**: ${process.platform}\n` 54 | ) 55 | 56 | return url.href 57 | } 58 | 59 | async restartTeletype () { 60 | const {packageManager} = this.props 61 | await packageManager.deactivatePackage('teletype') 62 | await packageManager.activatePackage('teletype') 63 | } 64 | } 65 | 66 | function getTeletypeVersion () { 67 | return require('../package.json').version 68 | } 69 | -------------------------------------------------------------------------------- /lib/package-outdated-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | 4 | module.exports = 5 | class PackageOutdatedComponent { 6 | constructor (props) { 7 | this.props = props 8 | etch.initialize(this) 9 | } 10 | 11 | update (props) { 12 | Object.assign(this.props, props) 13 | return etch.update(this) 14 | } 15 | 16 | render () { 17 | return $.div({className: 'PackageOutdatedComponent'}, 18 | $.h3(null, 'Teletype is out of date'), 19 | $.p(null, 'You will need to update the package to resume collaborating.'), 20 | $.button( 21 | { 22 | ref: 'viewPackageSettingsButton', 23 | className: 'btn btn-primary btn-sm', 24 | onClick: this.viewPackageSettings 25 | }, 26 | 'Check Package Updates' 27 | ) 28 | ) 29 | } 30 | 31 | viewPackageSettings () { 32 | return this.props.workspace.open('atom://config/updates') 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/participants-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const getAvatarURL = require('./get-avatar-url') 4 | 5 | module.exports = 6 | class ParticipantsComponent { 7 | constructor (props) { 8 | this.props = props 9 | etch.initialize(this) 10 | } 11 | 12 | update (props) { 13 | Object.assign(this.props, props) 14 | return etch.update(this) 15 | } 16 | 17 | render () { 18 | let participantComponents 19 | 20 | if (this.props.portalBinding) { 21 | const {portal} = this.props.portalBinding 22 | const activeSiteIds = portal.getActiveSiteIds().sort((a, b) => a - b) 23 | participantComponents = activeSiteIds.map((siteId) => 24 | this.renderParticipant(siteId, portal.getSiteIdentity(siteId)) 25 | ) 26 | } else { 27 | const {localUserIdentity} = this.props 28 | participantComponents = [this.renderParticipant(1, localUserIdentity)] 29 | } 30 | 31 | return $.div({className: 'PortalParticipants'}, 32 | participantComponents[0], 33 | $.div({className: 'PortalParticipants-guests'}, 34 | participantComponents.slice(1) 35 | ) 36 | ) 37 | } 38 | 39 | renderParticipant (siteId, {login}) { 40 | const avatarSize = siteId === 1 ? 56 : 44 41 | return $.div( 42 | {className: `PortalParticipants-participant PortalParticipants-site-${siteId}`}, 43 | $.img({src: getAvatarURL(login, avatarSize)}) 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/popover-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const PortalListComponent = require('./portal-list-component') 4 | const SignInComponent = require('./sign-in-component') 5 | const PackageOutdatedComponent = require('./package-outdated-component') 6 | const PackageInitializationErrorComponent = require('./package-initialization-error-component') 7 | 8 | module.exports = 9 | class PopoverComponent { 10 | constructor (props) { 11 | this.props = props 12 | if (this.props.authenticationProvider) { 13 | this.props.authenticationProvider.onDidChange(() => { this.update() }) 14 | } 15 | etch.initialize(this) 16 | } 17 | 18 | update () { 19 | return etch.update(this) 20 | } 21 | 22 | render () { 23 | const { 24 | isClientOutdated, initializationError, 25 | authenticationProvider, portalBindingManager, 26 | commandRegistry, clipboard, workspace, notificationManager, packageManager, getAtomVersion 27 | } = this.props 28 | 29 | let activeComponent 30 | if (isClientOutdated) { 31 | activeComponent = $(PackageOutdatedComponent, { 32 | ref: 'packageOutdatedComponent', 33 | workspace 34 | }) 35 | } else if (initializationError) { 36 | activeComponent = $(PackageInitializationErrorComponent, { 37 | ref: 'packageInitializationErrorComponent', 38 | packageManager, 39 | getAtomVersion, 40 | initializationError 41 | }) 42 | } else if (this.props.authenticationProvider.isSignedIn()) { 43 | activeComponent = $(PortalListComponent, { 44 | ref: 'portalListComponent', 45 | localUserIdentity: authenticationProvider.getIdentity(), 46 | portalBindingManager, 47 | clipboard, 48 | commandRegistry, 49 | notificationManager 50 | }) 51 | } else { 52 | activeComponent = $(SignInComponent, { 53 | ref: 'signInComponent', 54 | authenticationProvider, 55 | commandRegistry 56 | }) 57 | } 58 | 59 | return $.div({className: 'TeletypePopoverComponent'}, activeComponent) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/portal-binding-manager.js: -------------------------------------------------------------------------------- 1 | const {Emitter} = require('atom') 2 | const HostPortalBinding = require('./host-portal-binding') 3 | const GuestPortalBinding = require('./guest-portal-binding') 4 | const {findPortalId} = require('./portal-id-helpers') 5 | 6 | module.exports = 7 | class PortalBindingManager { 8 | constructor ({client, workspace, notificationManager}) { 9 | this.emitter = new Emitter() 10 | this.client = client 11 | this.workspace = workspace 12 | this.notificationManager = notificationManager 13 | this.hostPortalBindingPromise = null 14 | this.promisesByGuestPortalId = new Map() 15 | } 16 | 17 | dispose () { 18 | const disposePromises = [] 19 | 20 | if (this.hostPortalBindingPromise) { 21 | const disposePromise = this.hostPortalBindingPromise.then((portalBinding) => { 22 | portalBinding.close() 23 | }) 24 | disposePromises.push(disposePromise) 25 | } 26 | 27 | this.promisesByGuestPortalId.forEach(async (portalBindingPromise) => { 28 | const disposePromise = portalBindingPromise.then((portalBinding) => { 29 | if (portalBinding) portalBinding.leave() 30 | }) 31 | disposePromises.push(disposePromise) 32 | }) 33 | 34 | return Promise.all(disposePromises) 35 | } 36 | 37 | createHostPortalBinding () { 38 | if (this.hostPortalBindingPromise == null) { 39 | this.hostPortalBindingPromise = this._createHostPortalBinding() 40 | this.hostPortalBindingPromise.then((binding) => { 41 | if (!binding) this.hostPortalBindingPromise = null 42 | }) 43 | } 44 | 45 | return this.hostPortalBindingPromise 46 | } 47 | 48 | async _createHostPortalBinding () { 49 | const portalBinding = new HostPortalBinding({ 50 | client: this.client, 51 | workspace: this.workspace, 52 | notificationManager: this.notificationManager, 53 | didDispose: () => { this.didDisposeHostPortalBinding() } 54 | }) 55 | 56 | if (await portalBinding.initialize()) { 57 | this.emitter.emit('did-change') 58 | return portalBinding 59 | } 60 | } 61 | 62 | getHostPortalBinding () { 63 | return this.hostPortalBindingPromise 64 | ? this.hostPortalBindingPromise 65 | : Promise.resolve(null) 66 | } 67 | 68 | didDisposeHostPortalBinding () { 69 | this.hostPortalBindingPromise = null 70 | this.emitter.emit('did-change') 71 | } 72 | 73 | createGuestPortalBinding (portalId) { 74 | let promise = this.promisesByGuestPortalId.get(portalId) 75 | if (promise) { 76 | promise.then((binding) => { 77 | if (binding) binding.activate() 78 | }) 79 | } else { 80 | promise = this._createGuestPortalBinding(portalId) 81 | promise.then((binding) => { 82 | if (!binding) this.promisesByGuestPortalId.delete(portalId) 83 | }) 84 | this.promisesByGuestPortalId.set(portalId, promise) 85 | } 86 | 87 | return promise 88 | } 89 | 90 | async _createGuestPortalBinding (portalId) { 91 | const portalBinding = new GuestPortalBinding({ 92 | portalId, 93 | client: this.client, 94 | workspace: this.workspace, 95 | notificationManager: this.notificationManager, 96 | didDispose: () => { this.didDisposeGuestPortalBinding(portalBinding) } 97 | }) 98 | 99 | if (await portalBinding.initialize()) { 100 | this.workspace.getElement().classList.add('teletype-Guest') 101 | this.emitter.emit('did-change') 102 | return portalBinding 103 | } 104 | } 105 | 106 | async getGuestPortalBindings () { 107 | const portalBindings = await Promise.all(this.promisesByGuestPortalId.values()) 108 | return portalBindings.filter((binding) => binding != null) 109 | } 110 | 111 | async getRemoteEditors () { 112 | const remoteEditors = [] 113 | for (const bindingPromise of this.promisesByGuestPortalId.values()) { 114 | const portalBinding = await bindingPromise 115 | remoteEditors.push(...portalBinding.getRemoteEditors()) 116 | } 117 | 118 | return remoteEditors 119 | } 120 | 121 | async getActiveGuestPortalBinding () { 122 | const activePaneItem = this.workspace.getActivePaneItem() 123 | for (const [_, portalBindingPromise] of this.promisesByGuestPortalId) { // eslint-disable-line no-unused-vars 124 | const portalBinding = await portalBindingPromise 125 | if (portalBinding.hasPaneItem(activePaneItem)) { 126 | return portalBinding 127 | } 128 | } 129 | } 130 | 131 | async hasActivePortals () { 132 | const hostPortalBinding = await this.getHostPortalBinding() 133 | const guestPortalBindings = await this.getGuestPortalBindings() 134 | 135 | return (hostPortalBinding != null) || (guestPortalBindings.length > 0) 136 | } 137 | 138 | async getRemoteEditorForURI (uri) { 139 | const uriComponents = uri.replace('atom://teletype/', '').split('/') 140 | 141 | const portalId = findPortalId(uriComponents[1]) 142 | if (uriComponents[0] !== 'portal' || !portalId) return null 143 | 144 | const editorProxyId = Number(uriComponents[3]) 145 | if (uriComponents[2] !== 'editor' || Number.isNaN(editorProxyId)) return null 146 | 147 | const guestPortalBindingPromise = this.promisesByGuestPortalId.get(portalId) 148 | if (guestPortalBindingPromise) { 149 | const guestPortalBinding = await guestPortalBindingPromise 150 | return guestPortalBinding.getRemoteEditor(editorProxyId) 151 | } else { 152 | throw new Error('Cannot open an editor belonging to a portal that has not been joined') 153 | } 154 | } 155 | 156 | didDisposeGuestPortalBinding (portalBinding) { 157 | this.promisesByGuestPortalId.delete(portalBinding.portalId) 158 | if (this.promisesByGuestPortalId.size === 0) { 159 | this.workspace.getElement().classList.remove('teletype-Guest') 160 | } 161 | this.emitter.emit('did-change') 162 | } 163 | 164 | onDidChange (callback) { 165 | return this.emitter.on('did-change', callback) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /lib/portal-id-helpers.js: -------------------------------------------------------------------------------- 1 | const CONTAINS_UUID_REGEXP = /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ 2 | 3 | function findPortalId (string) { 4 | if (!string) return null 5 | 6 | const match = string.match(CONTAINS_UUID_REGEXP) 7 | return match ? match[0] : null 8 | } 9 | 10 | module.exports = {findPortalId} 11 | -------------------------------------------------------------------------------- /lib/portal-list-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const HostPortalBindingComponent = require('./host-portal-binding-component') 4 | const GuestPortalBindingComponent = require('./guest-portal-binding-component') 5 | const JoinPortalComponent = require('./join-portal-component') 6 | 7 | module.exports = 8 | class PortalListComponent { 9 | constructor (props) { 10 | this.props = props 11 | this.props.initializing = true 12 | etch.initialize(this) 13 | 14 | this.subscriptions = this.props.portalBindingManager.onDidChange(async () => { 15 | await this.fetchModel() 16 | etch.update(this) 17 | }) 18 | 19 | let resolveInitializationPromise 20 | this.initializationPromise = new Promise((resolve) => { 21 | resolveInitializationPromise = resolve 22 | }) 23 | this.fetchModel().then(async () => { 24 | this.props.initializing = false 25 | await etch.update(this) 26 | resolveInitializationPromise() 27 | }) 28 | } 29 | 30 | destroy () { 31 | this.subscriptions.dispose() 32 | return etch.destroy(this) 33 | } 34 | 35 | async fetchModel () { 36 | const {portalBindingManager} = this.props 37 | this.props.hostPortalBinding = await portalBindingManager.getHostPortalBinding() 38 | this.props.guestPortalBindings = await portalBindingManager.getGuestPortalBindings() 39 | } 40 | 41 | async update (props) { 42 | Object.assign(this.props, props) 43 | await this.fetchModel() 44 | return etch.update(this) 45 | } 46 | 47 | render () { 48 | if (this.props.initializing) { 49 | return $.div({className: 'PortalListComponent--initializing', ref: 'initializationSpinner'}, 50 | $.span({className: 'loading loading-spinner-tiny inline-block'}) 51 | ) 52 | } else { 53 | return $.div({className: 'PortalListComponent'}, 54 | this.renderHostPortalBindingComponent(), 55 | this.renderGuestPortalBindingComponents(), 56 | this.renderJoinPortalComponent() 57 | ) 58 | } 59 | } 60 | 61 | renderHostPortalBindingComponent () { 62 | return $(HostPortalBindingComponent, { 63 | ref: 'hostPortalBindingComponent', 64 | clipboard: this.props.clipboard, 65 | localUserIdentity: this.props.localUserIdentity, 66 | portalBindingManager: this.props.portalBindingManager, 67 | portalBinding: this.props.hostPortalBinding 68 | }) 69 | } 70 | 71 | renderGuestPortalBindingComponents () { 72 | const portalBindingComponents = this.props.guestPortalBindings.map((portalBinding) => ( 73 | $(GuestPortalBindingComponent, {portalBinding}) 74 | )) 75 | 76 | return $.div( 77 | { 78 | ref: 'guestPortalBindingsContainer', 79 | className: 'PortalListComponent-GuestPortalsContainer' 80 | }, 81 | portalBindingComponents 82 | ) 83 | } 84 | 85 | renderJoinPortalComponent () { 86 | return $(JoinPortalComponent, { 87 | ref: 'joinPortalComponent', 88 | portalBindingManager: this.props.portalBindingManager, 89 | commandRegistry: this.props.commandRegistry, 90 | clipboard: this.props.clipboard, 91 | notificationManager: this.props.notificationManager 92 | }) 93 | } 94 | 95 | async showJoinPortalPrompt () { 96 | await this.initializationPromise 97 | await this.refs.joinPortalComponent.showPrompt() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/portal-status-bar-indicator.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable} = require('atom') 2 | const PopoverComponent = require('./popover-component') 3 | 4 | module.exports = 5 | class PortalStatusBarIndicator { 6 | constructor (props) { 7 | this.props = props 8 | this.subscriptions = new CompositeDisposable() 9 | this.element = buildElement(props) 10 | this.popoverComponent = new PopoverComponent(props) 11 | 12 | if (props.portalBindingManager) { 13 | this.portalBindingManager = props.portalBindingManager 14 | this.subscriptions.add(this.portalBindingManager.onDidChange(() => { 15 | this.updatePortalStatus() 16 | })) 17 | } 18 | } 19 | 20 | attach () { 21 | const PRIORITY_BETWEEN_BRANCH_NAME_AND_GRAMMAR = -40 22 | this.tile = this.props.statusBar.addRightTile({ 23 | item: this, 24 | priority: PRIORITY_BETWEEN_BRANCH_NAME_AND_GRAMMAR 25 | }) 26 | this.tooltip = this.props.tooltipManager.add( 27 | this.element, 28 | { 29 | item: this.popoverComponent, 30 | class: 'TeletypePopoverTooltip', 31 | trigger: 'click', 32 | placement: 'top' 33 | } 34 | ) 35 | } 36 | 37 | destroy () { 38 | if (this.tile) this.tile.destroy() 39 | if (this.tooltip) this.tooltip.dispose() 40 | this.subscriptions.dispose() 41 | } 42 | 43 | showPopover () { 44 | if (!this.isPopoverVisible()) this.element.click() 45 | } 46 | 47 | hidePopover () { 48 | if (this.isPopoverVisible()) this.element.click() 49 | } 50 | 51 | isPopoverVisible () { 52 | return document.contains(this.popoverComponent.element) 53 | } 54 | 55 | async updatePortalStatus () { 56 | const transmitting = await this.portalBindingManager.hasActivePortals() 57 | if (transmitting) { 58 | this.element.classList.add('transmitting') 59 | } else { 60 | this.element.classList.remove('transmitting') 61 | } 62 | } 63 | } 64 | 65 | function buildElement (props) { 66 | const anchor = document.createElement('a') 67 | anchor.classList.add('PortalStatusBarIndicator', 'inline-block') 68 | if (props.isClientOutdated) anchor.classList.add('outdated') 69 | if (props.initializationError) anchor.classList.add('initialization-error') 70 | 71 | const icon = document.createElement('span') 72 | icon.classList.add('icon', 'icon-radio-tower') 73 | anchor.appendChild(icon) 74 | 75 | return anchor 76 | } 77 | -------------------------------------------------------------------------------- /lib/sign-in-component.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, TextEditor} = require('atom') 2 | const etch = require('etch') 3 | const $ = etch.dom 4 | 5 | module.exports = 6 | class SignInComponent { 7 | constructor (props) { 8 | this.props = props 9 | etch.initialize(this) 10 | 11 | this.refs.editor.onDidChange(() => { 12 | const token = this.refs.editor.getText().trim() 13 | this.refs.loginButton.disabled = !token 14 | }) 15 | 16 | this.disposables = new CompositeDisposable() 17 | this.disposables.add(this.props.authenticationProvider.onDidChange(() => { 18 | etch.update(this) 19 | })) 20 | this.disposables.add(this.props.commandRegistry.add(this.element, { 21 | 'core:confirm': this.signIn.bind(this) 22 | })) 23 | } 24 | 25 | destroy () { 26 | this.disposables.dispose() 27 | return etch.destroy(this) 28 | } 29 | 30 | update (props) { 31 | Object.assign(this.props, props) 32 | return etch.update(this) 33 | } 34 | 35 | render () { 36 | return $.div({className: 'SignInComponent', tabIndex: -1}, 37 | $.span({className: 'SignInComponent-GitHubLogo'}), 38 | $.h3(null, 'Sign in with GitHub'), 39 | this.renderSigningInIndicator(), 40 | this.renderTokenPrompt() 41 | ) 42 | } 43 | 44 | renderSigningInIndicator () { 45 | let props = {} 46 | if (this.props.authenticationProvider.isSigningIn()) { 47 | props.className = 'loading loading-spinner-tiny inline-block' 48 | } else { 49 | props.style = {display: 'none'} 50 | } 51 | 52 | return $.span(props) 53 | } 54 | 55 | renderTokenPrompt () { 56 | const props = this.props.authenticationProvider.isSigningIn() ? {style: {display: 'none'}} : null 57 | 58 | return $.div(props, 59 | $.p(null, 60 | 'Visit ', 61 | $.a({href: 'https://teletype.atom.io/login', className: 'text-info'}, 'teletype.atom.io/login'), 62 | ' to generate an authentication token and paste it below:' 63 | ), 64 | this.renderErrorMessage(), 65 | 66 | $(TextEditor, {ref: 'editor', mini: true, placeholderText: 'Enter your token...'}), 67 | $.div(null, 68 | $.button( 69 | { 70 | ref: 'loginButton', 71 | type: 'button', 72 | className: 'btn btn-primary btn-sm inline-block-tight', 73 | onClick: this.signIn, 74 | disabled: true 75 | }, 76 | 'Sign in' 77 | ) 78 | ) 79 | ) 80 | } 81 | 82 | renderErrorMessage () { 83 | return this.props.invalidToken 84 | ? $.p({ref: 'errorMessage', className: 'error-messages'}, 'That token does not appear to be valid.') 85 | : null 86 | } 87 | 88 | async signIn () { 89 | const {editor} = this.refs 90 | const token = editor.getText().trim() 91 | const signedIn = token ? await this.props.authenticationProvider.signIn(token) : false 92 | 93 | if (signedIn) { 94 | await this.update({invalidToken: false}) 95 | } else { 96 | editor.setText('') 97 | await this.update({invalidToken: true}) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/site-positions-component.js: -------------------------------------------------------------------------------- 1 | const etch = require('etch') 2 | const $ = etch.dom 3 | const getAvatarURL = require('./get-avatar-url') 4 | const {FollowState} = require('@atom/teletype-client') 5 | 6 | module.exports = 7 | class SitePositionsComponent { 8 | constructor (props) { 9 | this.props = { 10 | positionsBySiteId: {} 11 | } 12 | Object.assign(this.props, props) 13 | etch.initialize(this) 14 | } 15 | 16 | destroy () { 17 | return etch.destroy(this) 18 | } 19 | 20 | update (props) { 21 | Object.assign(this.props, props) 22 | return etch.update(this) 23 | } 24 | 25 | show (containerElement) { 26 | containerElement.appendChild(this.element) 27 | } 28 | 29 | hide () { 30 | this.element.remove() 31 | } 32 | 33 | render () { 34 | const otherSiteIds = Object.keys(this.props.positionsBySiteId) 35 | .map((siteId) => parseInt(siteId)) 36 | .filter((siteId) => siteId !== this.props.portal.siteId) 37 | 38 | return $.div({className: 'SitePositionsComponent'}, 39 | otherSiteIds.map((siteId) => this.renderSite(siteId)) 40 | ) 41 | } 42 | 43 | renderSite (siteId) { 44 | const {portal} = this.props 45 | 46 | const {login} = portal.getSiteIdentity(siteId) 47 | const color = this.isCursorVisibleForSite(siteId) ? `color--site-${siteId}` : '' 48 | const location = this.getLocationForSite(siteId) 49 | const onClick = (location === 'viewing-non-portal-item') 50 | ? () => {} 51 | : () => this.onSelectSiteId(siteId) 52 | 53 | return $.div({className: `SitePositionsComponent-site site-${siteId} ${location} ${color}`}, 54 | (portal.getFollowedSiteId() === siteId) ? $.div({className: 'icon icon-link'}) : null, 55 | $.img({ 56 | src: getAvatarURL(login, 80), 57 | onClick 58 | }) 59 | ) 60 | } 61 | 62 | onSelectSiteId (siteId) { 63 | if (siteId === this.props.portal.getFollowedSiteId()) { 64 | this.props.portal.unfollow() 65 | } else { 66 | this.props.portal.follow(siteId) 67 | } 68 | } 69 | 70 | // Private 71 | isCursorVisibleForSite (siteId) { 72 | const followState = this.props.positionsBySiteId[siteId].followState 73 | return this.getLocationForSite(siteId) === 'viewing-current-editor' && 74 | (followState === FollowState.DISCONNECTED || followState === FollowState.EXTENDED) 75 | } 76 | 77 | // Private 78 | getLocationForSite (siteId) { 79 | const {portal, positionsBySiteId} = this.props 80 | const localPosition = positionsBySiteId[portal.siteId] 81 | const localEditorProxy = localPosition && localPosition.editorProxy 82 | const editorProxyForSite = positionsBySiteId[siteId].editorProxy 83 | 84 | if (editorProxyForSite == null) { 85 | return 'viewing-non-portal-item' 86 | } else if (editorProxyForSite === localEditorProxy) { 87 | return 'viewing-current-editor' 88 | } else { 89 | return 'viewing-other-editor' 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/teletype-package.js: -------------------------------------------------------------------------------- 1 | const {TeletypeClient, Errors} = require('@atom/teletype-client') 2 | const {CompositeDisposable} = require('atom') 3 | const PortalBindingManager = require('./portal-binding-manager') 4 | const PortalStatusBarIndicator = require('./portal-status-bar-indicator') 5 | const AuthenticationProvider = require('./authentication-provider') 6 | const CredentialCache = require('./credential-cache') 7 | const TeletypeService = require('./teletype-service') 8 | const {findPortalId} = require('./portal-id-helpers') 9 | const JoinViaExternalAppDialog = require('./join-via-external-app-dialog') 10 | 11 | module.exports = 12 | class TeletypePackage { 13 | constructor (options) { 14 | const { 15 | baseURL, config, clipboard, commandRegistry, credentialCache, getAtomVersion, 16 | notificationManager, packageManager, peerConnectionTimeout, pubSubGateway, 17 | pusherKey, pusherOptions, tetherDisconnectWindow, tooltipManager, 18 | workspace 19 | } = options 20 | 21 | this.config = config 22 | this.workspace = workspace 23 | this.notificationManager = notificationManager 24 | this.packageManager = packageManager 25 | this.commandRegistry = commandRegistry 26 | this.tooltipManager = tooltipManager 27 | this.clipboard = clipboard 28 | this.pubSubGateway = pubSubGateway 29 | this.pusherKey = pusherKey 30 | this.pusherOptions = pusherOptions 31 | this.baseURL = baseURL 32 | this.getAtomVersion = getAtomVersion 33 | this.peerConnectionTimeout = peerConnectionTimeout 34 | this.tetherDisconnectWindow = tetherDisconnectWindow 35 | this.credentialCache = credentialCache || new CredentialCache() 36 | this.client = new TeletypeClient({ 37 | pusherKey: this.pusherKey, 38 | pusherOptions: this.pusherOptions, 39 | baseURL: this.baseURL, 40 | pubSubGateway: this.pubSubGateway, 41 | connectionTimeout: this.peerConnectionTimeout, 42 | tetherDisconnectWindow: this.tetherDisconnectWindow 43 | }) 44 | this.client.onConnectionError(this.handleConnectionError.bind(this)) 45 | this.portalBindingManagerPromise = null 46 | this.joinViaExternalAppDialog = new JoinViaExternalAppDialog({config, commandRegistry, workspace}) 47 | this.subscriptions = new CompositeDisposable() 48 | } 49 | 50 | activate () { 51 | console.log('teletype: Using pusher key:', this.pusherKey) 52 | console.log('teletype: Using base URL:', this.baseURL) 53 | 54 | this.subscriptions.add(this.commandRegistry.add('atom-workspace.teletype-Authenticated', { 55 | 'teletype:sign-out': () => this.signOut() 56 | })) 57 | this.subscriptions.add(this.commandRegistry.add('atom-workspace', { 58 | 'teletype:share-portal': () => this.sharePortal() 59 | })) 60 | this.subscriptions.add(this.commandRegistry.add('atom-workspace', { 61 | 'teletype:join-portal': () => this.joinPortal() 62 | })) 63 | this.subscriptions.add(this.commandRegistry.add('teletype-RemotePaneItem', { 64 | 'teletype:leave-portal': () => this.leavePortal() 65 | })) 66 | this.subscriptions.add(this.commandRegistry.add('atom-workspace.teletype-Host', { 67 | 'teletype:copy-portal-url': () => this.copyHostPortalURI() 68 | })) 69 | this.subscriptions.add(this.commandRegistry.add('atom-workspace.teletype-Host', { 70 | 'teletype:close-portal': () => this.closeHostPortal() 71 | })) 72 | 73 | // Initiate sign-in, which will continue asynchronously, since we don't want 74 | // to block here. 75 | this.signInUsingSavedToken() 76 | this.registerRemoteEditorOpener() 77 | } 78 | 79 | async deactivate () { 80 | this.initializationError = null 81 | 82 | this.subscriptions.dispose() 83 | this.subscriptions = new CompositeDisposable() 84 | 85 | if (this.portalStatusBarIndicator) this.portalStatusBarIndicator.destroy() 86 | 87 | if (this.portalBindingManagerPromise) { 88 | const manager = await this.portalBindingManagerPromise 89 | await manager.dispose() 90 | } 91 | } 92 | 93 | async handleURI (parsedURI, rawURI) { 94 | const portalId = findPortalId(parsedURI.pathname) || rawURI 95 | 96 | if (this.config.get('teletype.askBeforeJoiningPortalViaExternalApp')) { 97 | const {EXIT_STATUS} = JoinViaExternalAppDialog 98 | 99 | const status = await this.joinViaExternalAppDialog.show(rawURI) 100 | switch (status) { 101 | case EXIT_STATUS.CONFIRM_ONCE: 102 | return this.joinPortal(portalId) 103 | case EXIT_STATUS.CONFIRM_ALWAYS: 104 | this.config.set('teletype.askBeforeJoiningPortalViaExternalApp', false) 105 | return this.joinPortal(portalId) 106 | default: 107 | break 108 | } 109 | } else { 110 | return this.joinPortal(portalId) 111 | } 112 | } 113 | 114 | async sharePortal () { 115 | this.showPopover() 116 | 117 | if (await this.isSignedIn()) { 118 | const manager = await this.getPortalBindingManager() 119 | const portalBinding = await manager.createHostPortalBinding() 120 | if (portalBinding) return portalBinding.portal 121 | } 122 | } 123 | 124 | async joinPortal (id) { 125 | this.showPopover() 126 | 127 | if (await this.isSignedIn()) { 128 | if (id) { 129 | const manager = await this.getPortalBindingManager() 130 | const portalBinding = await manager.createGuestPortalBinding(id) 131 | if (portalBinding) return portalBinding.portal 132 | } else { 133 | await this.showJoinPortalPrompt() 134 | } 135 | } 136 | } 137 | 138 | async closeHostPortal () { 139 | this.showPopover() 140 | 141 | const manager = await this.getPortalBindingManager() 142 | const hostPortalBinding = await manager.getHostPortalBinding() 143 | hostPortalBinding.close() 144 | } 145 | 146 | async copyHostPortalURI () { 147 | const manager = await this.getPortalBindingManager() 148 | const hostPortalBinding = await manager.getHostPortalBinding() 149 | atom.clipboard.write(hostPortalBinding.uri) 150 | } 151 | 152 | async leavePortal () { 153 | this.showPopover() 154 | 155 | const manager = await this.getPortalBindingManager() 156 | const guestPortalBinding = await manager.getActiveGuestPortalBinding() 157 | guestPortalBinding.leave() 158 | } 159 | 160 | provideTeletype () { 161 | return new TeletypeService({teletypePackage: this}) 162 | } 163 | 164 | async consumeStatusBar (statusBar) { 165 | const teletypeClient = await this.getClient() 166 | const portalBindingManager = await this.getPortalBindingManager() 167 | const authenticationProvider = await this.getAuthenticationProvider() 168 | this.portalStatusBarIndicator = new PortalStatusBarIndicator({ 169 | statusBar, 170 | teletypeClient, 171 | portalBindingManager, 172 | authenticationProvider, 173 | isClientOutdated: this.isClientOutdated, 174 | initializationError: this.initializationError, 175 | tooltipManager: this.tooltipManager, 176 | commandRegistry: this.commandRegistry, 177 | clipboard: this.clipboard, 178 | workspace: this.workspace, 179 | notificationManager: this.notificationManager, 180 | packageManager: this.packageManager, 181 | getAtomVersion: this.getAtomVersion 182 | }) 183 | 184 | this.portalStatusBarIndicator.attach() 185 | } 186 | 187 | registerRemoteEditorOpener () { 188 | this.subscriptions.add(this.workspace.addOpener((uri) => { 189 | if (uri.startsWith('atom://teletype/')) { 190 | return this.getRemoteEditorForURI(uri) 191 | } else { 192 | return null 193 | } 194 | })) 195 | } 196 | 197 | async getRemoteEditorForURI (uri) { 198 | const portalBindingManager = await this.getPortalBindingManager() 199 | if (portalBindingManager && await this.isSignedIn()) { 200 | return portalBindingManager.getRemoteEditorForURI(uri) 201 | } 202 | } 203 | 204 | async signInUsingSavedToken () { 205 | const authenticationProvider = await this.getAuthenticationProvider() 206 | if (authenticationProvider) { 207 | return authenticationProvider.signInUsingSavedToken() 208 | } else { 209 | return false 210 | } 211 | } 212 | 213 | async signOut () { 214 | const authenticationProvider = await this.getAuthenticationProvider() 215 | if (authenticationProvider) { 216 | this.portalStatusBarIndicator.showPopover() 217 | await authenticationProvider.signOut() 218 | } 219 | } 220 | 221 | async isSignedIn () { 222 | const authenticationProvider = await this.getAuthenticationProvider() 223 | if (authenticationProvider) { 224 | return authenticationProvider.isSignedIn() 225 | } else { 226 | return false 227 | } 228 | } 229 | 230 | showPopover () { 231 | if (!this.portalStatusBarIndicator) return 232 | 233 | this.portalStatusBarIndicator.showPopover() 234 | } 235 | 236 | async showJoinPortalPrompt () { 237 | if (!this.portalStatusBarIndicator) return 238 | 239 | const {popoverComponent} = this.portalStatusBarIndicator 240 | const {portalListComponent} = popoverComponent.refs 241 | await portalListComponent.showJoinPortalPrompt() 242 | } 243 | 244 | handleConnectionError (event) { 245 | const message = 'Connection Error' 246 | const description = `An error occurred with a teletype connection: ${event.message}` 247 | this.notificationManager.addError(message, { 248 | description, 249 | dismissable: true 250 | }) 251 | } 252 | 253 | getAuthenticationProvider () { 254 | if (!this.authenticationProviderPromise) { 255 | this.authenticationProviderPromise = new Promise(async (resolve, reject) => { 256 | const client = await this.getClient() 257 | if (client) { 258 | resolve(new AuthenticationProvider({ 259 | client, 260 | credentialCache: this.credentialCache, 261 | notificationManager: this.notificationManager, 262 | workspace: this.workspace 263 | })) 264 | } else { 265 | this.authenticationProviderPromise = null 266 | resolve(null) 267 | } 268 | }) 269 | } 270 | 271 | return this.authenticationProviderPromise 272 | } 273 | 274 | getPortalBindingManager () { 275 | if (!this.portalBindingManagerPromise) { 276 | this.portalBindingManagerPromise = new Promise(async (resolve, reject) => { 277 | const client = await this.getClient() 278 | if (client) { 279 | resolve(new PortalBindingManager({ 280 | client, 281 | workspace: this.workspace, 282 | notificationManager: this.notificationManager 283 | })) 284 | } else { 285 | this.portalBindingManagerPromise = null 286 | resolve(null) 287 | } 288 | }) 289 | } 290 | 291 | return this.portalBindingManagerPromise 292 | } 293 | 294 | async getClient () { 295 | if (this.initializationError) return null 296 | if (this.isClientOutdated) return null 297 | 298 | try { 299 | await this.client.initialize() 300 | return this.client 301 | } catch (error) { 302 | if (error instanceof Errors.ClientOutOfDateError) { 303 | this.isClientOutdated = true 304 | } else { 305 | this.initializationError = error 306 | } 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /lib/teletype-service.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class TeletypeService { 3 | constructor ({teletypePackage}) { 4 | this.teletypePackage = teletypePackage 5 | } 6 | 7 | async getRemoteEditors () { 8 | const portalBindingManager = await this.teletypePackage.getPortalBindingManager() 9 | if (portalBindingManager) { 10 | return portalBindingManager.getRemoteEditors() 11 | } else { 12 | return [] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/uri-helpers.js: -------------------------------------------------------------------------------- 1 | function getPortalURI (portalId) { 2 | return 'atom://teletype/portal/' + portalId 3 | } 4 | 5 | function getEditorURI (portalId, editorProxyId) { 6 | return getPortalURI(portalId) + '/editor/' + editorProxyId 7 | } 8 | 9 | module.exports = {getEditorURI, getPortalURI} 10 | -------------------------------------------------------------------------------- /menus/teletype.json: -------------------------------------------------------------------------------- 1 | { 2 | "menu": [ 3 | { 4 | "label": "Packages", 5 | "submenu": [ 6 | { 7 | "label": "Teletype", 8 | "submenu": [ 9 | { 10 | "label": "Share Portal", 11 | "command": "teletype:share-portal" 12 | }, 13 | { 14 | "label": "Join Portal", 15 | "command": "teletype:join-portal" 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "teletype", 3 | "version": "0.13.4", 4 | "description": "Share your workspace with team members and collaborate on code in real time", 5 | "keywords": [ 6 | "collaboration", 7 | "collaborative-editing", 8 | "pair-programming", 9 | "real-time" 10 | ], 11 | "main": "index.js", 12 | "repository": "https://github.com/atom/teletype", 13 | "scripts": { 14 | "lint": "standard --verbose", 15 | "test": "atom --test test" 16 | }, 17 | "author": "", 18 | "license": "MIT", 19 | "atomTestRunner": "./test/setup", 20 | "devDependencies": { 21 | "@atom/teletype-server": "^0.18.2", 22 | "atom-mocha-test-runner": "^1.0.1", 23 | "deep-equal": "^1.0.1", 24 | "standard": "^10.0.3", 25 | "temp": "^0.8.3" 26 | }, 27 | "dependencies": { 28 | "@atom/teletype-client": "^0.38.4", 29 | "etch": "^0.12.6" 30 | }, 31 | "providedServices": { 32 | "teletype": { 33 | "description": "A set of APIs for packages to integrate with Teletype", 34 | "versions": { 35 | "0.0.1": "provideTeletype" 36 | } 37 | } 38 | }, 39 | "consumedServices": { 40 | "status-bar": { 41 | "versions": { 42 | "^1.0.0": "consumeStatusBar" 43 | } 44 | } 45 | }, 46 | "uriHandler": { 47 | "method": "handleURI", 48 | "deferActivation": false 49 | }, 50 | "engines": { 51 | "atom": ">=1.25.0" 52 | }, 53 | "configSchema": { 54 | "askBeforeJoiningPortalViaExternalApp": { 55 | "title": "Ask before joining a portal via an external application", 56 | "description": "When set, you will be asked for confirmation every time you follow a portal URL in a third-party application.", 57 | "type": "boolean", 58 | "default": true, 59 | "order": 1 60 | }, 61 | "dev": { 62 | "title": "Development Settings", 63 | "collapsed": true, 64 | "type": "object", 65 | "order": 2, 66 | "properties": { 67 | "baseURL": { 68 | "title": "API server base URL", 69 | "description": "This should only be changed for development purposes. Changes take effect on the next package activation.", 70 | "type": "string", 71 | "default": "https://api.teletype.atom.io", 72 | "order": 1 73 | }, 74 | "pusherKey": { 75 | "title": "Pusher service key", 76 | "description": "This should only be changed for development purposes. Changes take effect on the next package activation.", 77 | "type": "string", 78 | "default": "f119821248b7429bece3", 79 | "order": 2 80 | }, 81 | "pusherCluster": { 82 | "title": "Pusher cluster name", 83 | "description": "This should only be changed for development purposes. Changes take effect on the next package activation.", 84 | "type": "string", 85 | "default": "mt1", 86 | "order": 3 87 | } 88 | } 89 | } 90 | }, 91 | "standard": { 92 | "env": { 93 | "mocha": true 94 | }, 95 | "globals": [ 96 | "atom", 97 | "ErrorEvent" 98 | ], 99 | "ignore": [ 100 | "test/fixtures" 101 | ] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /styles/teletype.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | @import "octicon-mixins"; 3 | 4 | @font-family-monospace: Menlo, Consolas, 'DejaVu Sans Mono', monospace; 5 | @join-leave-button-width: 50px; 6 | 7 | .PortalStatusBarIndicator { 8 | // Remove margin on the icon since there is no text that follows 9 | &&&:before { 10 | margin-right: 0; 11 | } 12 | 13 | &.transmitting { 14 | color: @text-color-info; 15 | 16 | &:hover { 17 | color: @text-color-info; 18 | } 19 | } 20 | 21 | &.outdated, &.initialization-error { 22 | color: @text-color-error; 23 | 24 | &:hover { 25 | color: @text-color-error; 26 | } 27 | } 28 | } 29 | 30 | .TeletypePopoverTooltip { 31 | user-select: none; 32 | 33 | &.tooltip { 34 | width: 315px; 35 | padding-left: @component-padding; 36 | padding-right: @component-padding; 37 | box-sizing: border-box; 38 | } 39 | 40 | .tooltip-inner.tooltip-inner.tooltip-inner.tooltip-inner { 41 | padding: @component-padding; 42 | background-color: @tool-panel-background-color; 43 | border: 1px solid @base-border-color; 44 | box-shadow: 0 4px 8px hsla(0, 0, 0, .1); 45 | color: @text-color; 46 | max-height: 300px; 47 | white-space: normal; 48 | overflow-y: auto; 49 | } 50 | 51 | &.top .tooltip-arrow.tooltip-arrow { 52 | width: @component-padding; 53 | height: @component-padding; 54 | border-width: 0 0 1px 1px; 55 | border-color: @base-border-color; 56 | background-color: @tool-panel-background-color; 57 | border-bottom-right-radius: 2px; 58 | transform: rotate(-45deg); 59 | } 60 | } 61 | 62 | .TeletypePopoverComponent { 63 | height: 100%; 64 | display: flex; 65 | align-items: center; 66 | justify-content: center; 67 | margin: @component-padding * -1; // Used to 'undo' the built-in padding 68 | color: @text-color !important; 69 | } 70 | 71 | .PortalListComponent { 72 | flex: 1; 73 | display: flex; 74 | flex-direction: column; 75 | height: 100%; 76 | } 77 | 78 | .PortalListComponent--initializing { 79 | text-align: center; 80 | } 81 | 82 | .PortalParticipants { 83 | flex: auto; 84 | display: flex; 85 | flex-direction: row; 86 | align-items: center; 87 | 88 | &-guests { 89 | flex: auto; 90 | display: flex; 91 | flex-direction: row; 92 | align-items: center; 93 | align-content: start; 94 | flex-wrap: wrap; 95 | } 96 | 97 | &-participant { 98 | margin: @component-padding / 2; 99 | 100 | img { 101 | height: 22px; 102 | border-radius: 50% 103 | } 104 | } 105 | 106 | &-site-1 { 107 | margin-left: 0; 108 | 109 | img { 110 | height: 28px; 111 | } 112 | } 113 | } 114 | 115 | .HostPortalComponent { 116 | flex: none; 117 | 118 | &-status { 119 | display: flex; 120 | flex-direction: row; 121 | align-items: center; 122 | padding: 0 @component-padding; 123 | } 124 | 125 | &-share-toggle { 126 | flex: none; 127 | 128 | label { 129 | margin: 0; 130 | font-size: 80%; 131 | } 132 | } 133 | 134 | &-connection-info, 135 | &-GuestPortalsContainer { 136 | border-bottom: 1px solid @base-border-color; 137 | padding: 0 @component-padding; 138 | } 139 | 140 | &-connection-info { 141 | display: flex; 142 | flex-direction: column; 143 | align-items: stretch; 144 | padding: @component-padding @component-padding (@component-padding / 2); 145 | position: relative; 146 | 147 | &-heading { 148 | &.creating-portal { 149 | visibility: hidden; 150 | } 151 | 152 | h1 { 153 | margin-top: 0; 154 | margin-bottom: @component-padding / 2; 155 | font-weight: normal; 156 | font-size: 80%; 157 | 158 | &::before { 159 | line-height: 1em; 160 | margin-right: 5px; 161 | } 162 | } 163 | } 164 | 165 | &-portal-url { 166 | display: flex; 167 | flex-direction: row; 168 | margin-bottom: 5px; 169 | 170 | &.creating-portal { 171 | visibility: hidden; 172 | } 173 | 174 | .input-text { 175 | display: flex; 176 | flex: auto; 177 | } 178 | 179 | .btn { 180 | margin-left: @component-padding / 2; 181 | flex: none; 182 | } 183 | } 184 | 185 | &-spinner { 186 | position: absolute !important; 187 | left: 50%; 188 | top: 50%; 189 | transform: translate(-50%, -50%); 190 | } 191 | } 192 | 193 | .host-id-input { 194 | font-family: @font-family-monospace; 195 | font-size: 80%; 196 | color: @text-color; 197 | } 198 | } 199 | 200 | .PortalListComponent-GuestPortalsContainer { 201 | flex: 1; 202 | } 203 | 204 | .GuestPortalComponent { 205 | display: flex; 206 | flex-direction: row; 207 | align-items: center; 208 | padding: 0 @component-padding; 209 | 210 | &-leave.btn { 211 | flex: none; 212 | width: @join-leave-button-width; 213 | 214 | label { 215 | margin: 0; 216 | font-size: 80%; 217 | } 218 | } 219 | } 220 | 221 | .SignInComponent { 222 | text-align: center; 223 | padding: @component-padding; 224 | 225 | atom-text-editor { 226 | font-size: 12px !important; 227 | margin-bottom: @component-padding; 228 | text-align: left; 229 | } 230 | } 231 | 232 | .SignInComponent-GitHubLogo { 233 | .octicon(mark-github, 40px); 234 | } 235 | 236 | .JoinViaExternalAppDialog { 237 | padding: @component-padding; 238 | 239 | &-cancel { 240 | position: absolute; 241 | padding: @component-padding; 242 | top: 0; 243 | right: 0; 244 | text-align: right; 245 | &::before { 246 | margin-right: 0; 247 | } 248 | } 249 | 250 | &-uri { 251 | padding: @component-padding/2 @component-padding; 252 | color: @text-color-highlight; 253 | border: 1px solid @base-border-color; 254 | border-radius: @component-border-radius; 255 | background-color: @background-color-highlight; 256 | } 257 | 258 | &-footer { 259 | display: flex; 260 | align-items: center; 261 | justify-content: space-between; 262 | } 263 | 264 | .input-label { 265 | margin-left: 2em; 266 | margin-right: 3em; 267 | } 268 | 269 | .input-checkbox { 270 | margin-left: -2em; 271 | margin-right: .5em; 272 | } 273 | } 274 | 275 | .JoinPortalComponent--no-prompt, 276 | .JoinPortalComponent--prompt 277 | { 278 | border-top: 1px solid @base-border-color; 279 | } 280 | 281 | .JoinPortalComponent--no-prompt { 282 | flex: none; 283 | height: 40px; 284 | display: flex; 285 | flex-direction: row; 286 | align-items: center; 287 | justify-content: center; 288 | 289 | label { 290 | color: @text-color-subtle; 291 | font-size: 12px; 292 | margin: 0; 293 | } 294 | 295 | label:hover { 296 | color: @text-color-selected; 297 | cursor: pointer; 298 | } 299 | } 300 | 301 | .JoinPortalComponent--prompt { 302 | flex: none; 303 | height: 40px; 304 | text-align: left; 305 | display: flex; 306 | flex-direction: row; 307 | align-items: center; 308 | justify-content: center; 309 | padding: 0 @component-padding; 310 | 311 | atom-text-editor { 312 | flex: auto; 313 | font-size: 80% !important; 314 | } 315 | 316 | .btn { 317 | flex: none; 318 | width: @join-leave-button-width; 319 | margin-left: 5px; 320 | } 321 | } 322 | 323 | .cursors.blink-off .cursor.non-blinking { 324 | opacity: 1; 325 | } 326 | 327 | .SitePositionsComponent { 328 | @corner-margin: @component-padding * 1.5; 329 | @avatar-margin: @component-padding / 2; 330 | @avatar-size: 40px; 331 | 332 | display: flex; 333 | flex-direction: row; 334 | position: absolute; 335 | justify-content: flex-end; 336 | font-size: 16px; 337 | text-align: right; 338 | left: 0; 339 | right: @corner-margin; 340 | bottom: @corner-margin; 341 | overflow: hidden; 342 | pointer-events: none; 343 | 344 | img { 345 | cursor: pointer; 346 | height: @avatar-size; 347 | border: 2px solid transparent; 348 | border-radius: 50% 349 | } 350 | 351 | .SitePositionsComponent-site { 352 | margin-left: @avatar-margin; 353 | pointer-events: auto; 354 | border: solid 2px transparent; 355 | border-radius: 50%; 356 | 357 | &.viewing-non-portal-item { 358 | opacity: 0.5; 359 | } 360 | 361 | .icon-link { 362 | position: absolute; 363 | margin-left: 26px; 364 | margin-top: 26px; 365 | color: @text-color-info; 366 | pointer-events: none; 367 | 368 | &::before { 369 | position: relative; 370 | z-index: 2; 371 | } 372 | 373 | // background cover 374 | &::after { 375 | content: ""; 376 | position: absolute; 377 | z-index: 1; 378 | left: -2px; 379 | top: 4px; 380 | width: 15px; 381 | height: 11px; 382 | border-radius: 6px; 383 | background-color: @syntax-background-color; 384 | } 385 | } 386 | } 387 | } 388 | 389 | // Site colors 390 | .cursor.ParticipantCursor, // cursor 391 | .SitePositionsComponent-site.color { // avatar 392 | &--site-1 { border-color: @ui-site-color-1; } 393 | &--site-2 { border-color: @ui-site-color-2; } 394 | &--site-3 { border-color: @ui-site-color-3; } 395 | &--site-4 { border-color: @ui-site-color-4; } 396 | &--site-5 { border-color: @ui-site-color-5; } 397 | &--site-6 { border-color: spin(@ui-site-color-1, 25); } 398 | &--site-7 { border-color: spin(@ui-site-color-2, 25); } 399 | &--site-8 { border-color: spin(@ui-site-color-3, 25); } 400 | &--site-9 { border-color: spin(@ui-site-color-4, 25); } 401 | &--site-10 { border-color: spin(@ui-site-color-5, 25); } 402 | &--site-11 { border-color: spin(@ui-site-color-1, 65); } 403 | &--site-12 { border-color: spin(@ui-site-color-2, 65); } 404 | &--site-13 { border-color: spin(@ui-site-color-3, 65); } 405 | &--site-14 { border-color: spin(@ui-site-color-4, 65); } 406 | &--site-15 { border-color: spin(@ui-site-color-5, 65); } 407 | } 408 | 409 | .PackageOutdatedComponent, .PackageInitializationErrorComponent { 410 | color: @text-color; 411 | text-align: center; 412 | padding: 0 @component-padding @component-padding @component-padding; 413 | 414 | .btn { 415 | margin-bottom: @component-padding * 1.5; 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /test/buffer-binding.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const temp = require('temp') 5 | const {TextBuffer} = require('atom') 6 | const BufferBinding = require('../lib/buffer-binding') 7 | const FakeBufferProxy = require('./helpers/fake-buffer-proxy') 8 | 9 | suite('BufferBinding', function () { 10 | if (process.env.CI) this.timeout(process.env.TEST_TIMEOUT_IN_MS) 11 | 12 | test('relays changes to and from the shared buffer', () => { 13 | const buffer = new TextBuffer('hello\nworld') 14 | const binding = new BufferBinding({buffer}) 15 | const bufferProxy = new FakeBufferProxy({delegate: binding, text: buffer.getText()}) 16 | binding.setBufferProxy(bufferProxy) 17 | 18 | bufferProxy.simulateRemoteTextUpdate([ 19 | {oldStart: {row: 0, column: 0}, oldEnd: {row: 0, column: 5}, newText: 'goodbye'}, 20 | {oldStart: {row: 1, column: 0}, oldEnd: {row: 1, column: 0}, newText: 'cruel\n'} 21 | ]) 22 | assert.equal(buffer.getText(), 'goodbye\ncruel\nworld') 23 | assert.equal(bufferProxy.text, 'goodbye\ncruel\nworld') 24 | 25 | buffer.setTextInRange([[1, 0], [1, 5]], 'wonderful') 26 | assert.equal(buffer.getText(), 'goodbye\nwonderful\nworld') 27 | assert.equal(bufferProxy.text, 'goodbye\nwonderful\nworld') 28 | 29 | buffer.transact(() => { 30 | buffer.setTextInRange([[0, 0], [0, 4]], 'bye\n') 31 | buffer.setTextInRange([[2, 0], [3, 0]], '') 32 | buffer.setTextInRange([[2, 3], [2, 5]], 'ms') 33 | }) 34 | assert.equal(buffer.getText(), 'bye\nbye\nworms') 35 | assert.equal(bufferProxy.text, 'bye\nbye\nworms') 36 | }) 37 | 38 | test('does not relay empty changes to the shared buffer', () => { 39 | const buffer = new TextBuffer('hello\nworld') 40 | const binding = new BufferBinding({buffer}) 41 | const bufferProxy = new FakeBufferProxy({delegate: binding, text: buffer.getText()}) 42 | binding.setBufferProxy(bufferProxy) 43 | 44 | buffer.setTextInRange([[0, 0], [0, 0]], '') 45 | assert.equal(buffer.getText(), 'hello\nworld') 46 | assert.equal(bufferProxy.text, 'hello\nworld') 47 | }) 48 | 49 | test('flushes changes to disk when receiving a save request', async () => { 50 | const buffer = new TextBuffer('hello\nworld') 51 | // This line ensures saving works correctly even if the save function has been monkey-patched. 52 | buffer.save = () => {} 53 | 54 | const binding = new BufferBinding({buffer, isHost: true}) 55 | const bufferProxy = new FakeBufferProxy({delegate: binding, text: buffer.getText()}) 56 | binding.setBufferProxy(bufferProxy) 57 | 58 | // Calling binding.save with an in-memory buffer is ignored. 59 | try { 60 | await binding.save() 61 | } catch (error) { 62 | assert.ifError(error) 63 | } 64 | 65 | // Calling binding.save with an on-disk buffer flushes changes to disk. 66 | const filePath = temp.path() 67 | await buffer.saveAs(filePath) 68 | 69 | buffer.setText('changed') 70 | await binding.save() 71 | assert.equal(fs.readFileSync(filePath, 'utf8'), 'changed') 72 | }) 73 | 74 | test('relays path changes from host to guest', async () => { 75 | { 76 | const hostBuffer = new TextBuffer('') 77 | const hostBinding = new BufferBinding({buffer: hostBuffer, isHost: true}) 78 | const hostBufferProxy = new FakeBufferProxy({delegate: hostBinding, text: hostBuffer.getText()}) 79 | hostBinding.setBufferProxy(hostBufferProxy) 80 | 81 | await hostBuffer.saveAs(path.join(temp.path(), 'new-filename')) 82 | assert(hostBufferProxy.uri.includes('new-filename')) 83 | } 84 | 85 | { 86 | const guestBuffer = new TextBuffer('') 87 | const guestBinding = new BufferBinding({buffer: guestBuffer, isHost: false}) 88 | const guestBufferProxy = new FakeBufferProxy({delegate: guestBinding, text: guestBuffer.getText()}) 89 | guestBinding.setBufferProxy(guestBufferProxy) 90 | 91 | guestBufferProxy.simulateRemoteURIChange('some/uri/new-filename') 92 | assert(guestBuffer.getPath().includes('new-filename')) 93 | assert.equal(guestBufferProxy.uri, 'some/uri/new-filename') 94 | } 95 | }) 96 | 97 | suite('destroying the buffer', () => { 98 | test('on the host, disposes the underlying buffer proxy', () => { 99 | const buffer = new TextBuffer('') 100 | const binding = new BufferBinding({buffer, isHost: true}) 101 | const bufferProxy = new FakeBufferProxy({delegate: binding, text: buffer.getText()}) 102 | binding.setBufferProxy(bufferProxy) 103 | 104 | buffer.destroy() 105 | assert(bufferProxy.disposed) 106 | }) 107 | 108 | test('on guests, disposes the buffer binding', () => { 109 | const buffer = new TextBuffer('') 110 | const binding = new BufferBinding({buffer, isHost: false}) 111 | const bufferProxy = new FakeBufferProxy({delegate: binding, text: buffer.getText()}) 112 | binding.setBufferProxy(bufferProxy) 113 | 114 | buffer.destroy() 115 | assert(binding.disposed) 116 | assert(!bufferProxy.disposed) 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /test/credential-cache.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const CredentialCache = require('../lib/credential-cache') 3 | const {KeytarStrategy, SecurityBinaryStrategy, InMemoryStrategy} = CredentialCache 4 | 5 | suite('CredentialCache', async () => { 6 | test('get, set, and delete with various strategies', async () => { 7 | const cache = new CredentialCache() 8 | 9 | if (await KeytarStrategy.isValid()) { 10 | cache.strategy = new KeytarStrategy() 11 | 12 | await cache.set('foo', 'bar') 13 | assert.equal(await cache.get('foo'), 'bar') 14 | await cache.delete('foo') 15 | assert.equal(await cache.get('foo'), null) 16 | } else { 17 | console.warn('Skipping tests for CredentialCache.KeytarStrategy because keytar is not working in this build of Atom') 18 | } 19 | 20 | if (SecurityBinaryStrategy.isValid()) { 21 | cache.strategy = new SecurityBinaryStrategy() 22 | 23 | await cache.set('foo', 'bar') 24 | assert.equal(await cache.get('foo'), 'bar') 25 | await cache.delete('foo') 26 | assert.equal(await cache.get('foo'), null) 27 | } else { 28 | console.warn('Skipping tests for credential.SecurityBinaryStrategy because it only works on macOS') 29 | } 30 | 31 | cache.strategy = new InMemoryStrategy() 32 | await cache.set('foo', 'bar') 33 | assert.equal(await cache.get('foo'), 'bar') 34 | await cache.delete('foo') 35 | assert.equal(await cache.get('foo'), null) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/fixtures/sample.js: -------------------------------------------------------------------------------- 1 | const ELEMENTS = [ 2 | 'Hydrogen', 3 | 'Helium', 4 | 'Lithium', 5 | 'Beryllium', 6 | 'Boron', 7 | 'Carbon', 8 | 'Nitrogen', 9 | 'Oxygen', 10 | 'Fluorine', 11 | 'Neon', 12 | 'Sodium', 13 | 'Magnesium', 14 | 'Aluminum', 15 | 'Silicon', 16 | 'Phosphorus', 17 | 'Sulfur', 18 | 'Chlorine', 19 | 'Argon', 20 | 'Potassium', 21 | 'Calcium', 22 | 'Scandium', 23 | 'Titanium', 24 | 'Vanadium', 25 | 'Chromium', 26 | 'Manganese', 27 | 'Iron', 28 | 'Cobalt', 29 | 'Nickel', 30 | 'Copper', 31 | 'Zinc', 32 | 'Gallium', 33 | 'Germanium', 34 | 'Arsenic', 35 | 'Selenium', 36 | 'Bromine', 37 | 'Krypton', 38 | 'Rubidium', 39 | 'Strontium', 40 | 'Yttrium', 41 | 'Zirconium', 42 | 'Niobium', 43 | 'Molybdenum', 44 | 'Technetium', 45 | 'Ruthenium', 46 | 'Rhodium', 47 | 'Palladium', 48 | 'Silver', 49 | 'Cadmium', 50 | 'Indium', 51 | 'Tin', 52 | 'Antimony', 53 | 'Tellurium', 54 | 'Iodine', 55 | 'Xenon', 56 | 'Cesium', 57 | 'Barium', 58 | 'Lanthanum', 59 | 'Cerium', 60 | 'Praseodymium', 61 | 'Neodymium', 62 | 'Promethium', 63 | 'Samarium', 64 | 'Europium', 65 | 'Gadolinium', 66 | 'Terbium', 67 | 'Dysprosium', 68 | 'Holmium', 69 | 'Erbium', 70 | 'Thulium', 71 | 'Ytterbium', 72 | 'Lutetium', 73 | 'Hafnium', 74 | 'Tantalum', 75 | 'Tungsten', 76 | 'Rhenium', 77 | 'Osmium', 78 | 'Iridium', 79 | 'Platinum', 80 | 'Gold', 81 | 'Mercury', 82 | 'Thallium', 83 | 'Lead', 84 | 'Bismuth', 85 | 'Polonium', 86 | 'Astatine', 87 | 'Radon', 88 | 'Francium', 89 | 'Radium', 90 | 'Actinium', 91 | 'Thorium', 92 | 'Protactinium', 93 | 'Uranium', 94 | 'Neptunium', 95 | 'Plutonium', 96 | 'Americium', 97 | 'Curium', 98 | 'Berkelium', 99 | 'Californium', 100 | 'Einsteinium', 101 | 'Fermium', 102 | 'Mendelevium', 103 | 'Nobelium', 104 | 'Lawrencium', 105 | 'Rutherfordium', 106 | 'Dubnium', 107 | 'Seaborgium', 108 | 'Bohrium', 109 | 'Hassium', 110 | 'Meitnerium' 111 | ] 112 | 113 | var quicksort = function () { 114 | var sort = function(items) { 115 | if (items.length <= 1) return items; 116 | var pivot = items.shift(), current, left = [], right = []; 117 | while(items.length > 0) { 118 | current = items.shift(); 119 | current < pivot ? left.push(current) : right.push(current); 120 | } 121 | return sort(left).concat(pivot).concat(sort(right)); 122 | }; 123 | 124 | return sort(Array.apply(this, arguments)); 125 | }; 126 | 127 | quicksort(ELEMENTS); 128 | -------------------------------------------------------------------------------- /test/get-path-with-native-separators.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const getPathWithNativeSeparators = require('../lib/get-path-with-native-separators') 3 | 4 | suite('getPathWithNativeSeparators(uri, targetPlatform)', () => { 5 | test('posix to posix', () => { 6 | assert.equal(getPathWithNativeSeparators('/home/src/main.js', '/'), '/home/src/main.js') 7 | assert.equal(getPathWithNativeSeparators('src/main.js', '/'), 'src/main.js') 8 | }) 9 | 10 | test('posix to win32', () => { 11 | assert.equal(getPathWithNativeSeparators('/home/src/main.js', '\\'), '\\home\\src\\main.js') 12 | assert.equal(getPathWithNativeSeparators('src/main.js', '\\'), 'src\\main.js') 13 | }) 14 | 15 | test('win32 to posix', () => { 16 | assert.equal(getPathWithNativeSeparators('C:\\home\\src\\main.js', '/'), 'C:/home/src/main.js') 17 | assert.equal(getPathWithNativeSeparators('src\\main.js', '/'), 'src/main.js') 18 | }) 19 | 20 | test('win32 to win32', () => { 21 | assert.equal(getPathWithNativeSeparators('C:\\home\\src\\main.js', '\\'), 'C:\\home\\src\\main.js') 22 | assert.equal(getPathWithNativeSeparators('src\\main.js', '\\'), 'src\\main.js') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/guest-portal-binding.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') 3 | const {FollowState, TeletypeClient} = require('@atom/teletype-client') 4 | const FakePortal = require('./helpers/fake-portal') 5 | const FakeEditorProxy = require('./helpers/fake-editor-proxy') 6 | const GuestPortalBinding = require('../lib/guest-portal-binding') 7 | 8 | suite('GuestPortalBinding', () => { 9 | teardown(async () => { 10 | await destroyAtomEnvironments() 11 | }) 12 | 13 | test('handling an unexpected error when joining a portal', async () => { 14 | const stubPubSubGateway = {} 15 | const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) 16 | client.joinPortal = function () { 17 | throw new Error('It broke!') 18 | } 19 | const atomEnv = buildAtomEnvironment() 20 | const portalBinding = buildGuestPortalBinding(client, atomEnv, 'portal-id') 21 | 22 | const result = await portalBinding.initialize() 23 | assert.equal(result, false) 24 | 25 | assert.equal(atomEnv.notifications.getNotifications().length, 1) 26 | const {message, options} = atomEnv.notifications.getNotifications()[0] 27 | assert.equal(message, 'Failed to join portal') 28 | assert(options.description.includes('It broke!')) 29 | }) 30 | 31 | test('showing notifications when sites join or leave', async () => { 32 | const portal = new FakePortal() 33 | const client = { 34 | joinPortal () { 35 | return portal 36 | } 37 | } 38 | const atomEnv = buildAtomEnvironment() 39 | const portalBinding = buildGuestPortalBinding(client, atomEnv, 'portal-id') 40 | await portalBinding.initialize() 41 | 42 | atomEnv.notifications.clear() 43 | portal.delegate.siteDidJoin(2) 44 | assert.equal(atomEnv.notifications.getNotifications().length, 1) 45 | assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-1')) 46 | assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-2')) 47 | 48 | atomEnv.notifications.clear() 49 | portal.delegate.siteDidLeave(3) 50 | assert.equal(atomEnv.notifications.getNotifications().length, 1) 51 | assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-1')) 52 | assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-3')) 53 | }) 54 | 55 | test('switching the active editor in rapid succession', async () => { 56 | const stubPubSubGateway = {} 57 | const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) 58 | const portal = new FakePortal() 59 | client.joinPortal = function () { 60 | return portal 61 | } 62 | const atomEnv = buildAtomEnvironment() 63 | const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') 64 | await portalBinding.initialize() 65 | 66 | const activePaneItemChangeEvents = [] 67 | const disposable = atomEnv.workspace.onDidChangeActivePaneItem((item) => { 68 | activePaneItemChangeEvents.push(item) 69 | }) 70 | 71 | portalBinding.updateTether(FollowState.RETRACTED, new FakeEditorProxy('uri-1')) 72 | portalBinding.updateTether(FollowState.RETRACTED, new FakeEditorProxy('uri-2')) 73 | await portalBinding.updateTether(FollowState.RETRACTED, new FakeEditorProxy('uri-3')) 74 | 75 | assert.deepEqual( 76 | activePaneItemChangeEvents.map((i) => i.getTitle()), 77 | ['@site-1: uri-1', '@site-1: uri-2', '@site-1: uri-3'] 78 | ) 79 | assert.deepEqual( 80 | atomEnv.workspace.getPaneItems().map((i) => i.getTitle()), 81 | ['@site-1: uri-1', '@site-1: uri-2', '@site-1: uri-3'] 82 | ) 83 | 84 | disposable.dispose() 85 | }) 86 | 87 | test('switching the active editor to a remote editor that had been moved into a non-active pane', async () => { 88 | const stubPubSubGateway = {} 89 | const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) 90 | client.joinPortal = () => new FakePortal() 91 | const atomEnv = buildAtomEnvironment() 92 | const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') 93 | await portalBinding.initialize() 94 | 95 | const editorProxy1 = new FakeEditorProxy('editor-1') 96 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy1) 97 | 98 | const editorProxy2 = new FakeEditorProxy('editor-2') 99 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) 100 | 101 | const leftPane = atomEnv.workspace.getActivePane() 102 | const rightPane = leftPane.splitRight({moveActiveItem: true}) 103 | assert.equal(leftPane.getItems().length, 1) 104 | assert.equal(rightPane.getItems().length, 1) 105 | assert.equal(atomEnv.workspace.getActivePane(), rightPane) 106 | 107 | leftPane.activate() 108 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) 109 | assert.equal(leftPane.getItems().length, 1) 110 | assert.equal(rightPane.getItems().length, 1) 111 | assert.equal(atomEnv.workspace.getActivePane(), rightPane) 112 | }) 113 | 114 | test('relaying active editor changes', async () => { 115 | const portal = new FakePortal() 116 | const client = {joinPortal: () => portal} 117 | const atomEnv = buildAtomEnvironment() 118 | const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') 119 | await portalBinding.initialize() 120 | 121 | // Manually switching to another editor relays active editor changes to the client. 122 | await atomEnv.workspace.open() 123 | assert.equal(portal.activeEditorProxyChangeCount, 1) 124 | 125 | portal.setFollowState(FollowState.RETRACTED) 126 | 127 | // Updating tether and removing editor proxies while retracted doesn't relay 128 | // active editor changes to the client. 129 | const editorProxy1 = new FakeEditorProxy('editor-1') 130 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy1) 131 | assert.equal(portal.activeEditorProxyChangeCount, 1) 132 | 133 | const editorProxy2 = new FakeEditorProxy('editor-2') 134 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) 135 | assert.equal(portal.activeEditorProxyChangeCount, 1) 136 | 137 | const editorProxy3 = new FakeEditorProxy('editor-3') 138 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy3) 139 | assert.equal(portal.activeEditorProxyChangeCount, 1) 140 | 141 | editorProxy3.dispose() 142 | assert.equal(portal.activeEditorProxyChangeCount, 1) 143 | assert(atomEnv.workspace.getActivePaneItem().getTitle().includes('editor-2')) 144 | 145 | portal.setFollowState(FollowState.DISCONNECTED) 146 | 147 | // Destroying editor proxies while not retracted relays active editor changes to the client. 148 | editorProxy2.dispose() 149 | assert.equal(portal.activeEditorProxyChangeCount, 2) 150 | assert(atomEnv.workspace.getActivePaneItem().getTitle().includes('editor-1')) 151 | }) 152 | 153 | test('toggling site position components visibility when switching tabs', async () => { 154 | const stubPubSubGateway = {} 155 | const client = new TeletypeClient({pubSubGateway: stubPubSubGateway}) 156 | const portal = new FakePortal() 157 | client.joinPortal = () => portal 158 | const atomEnv = buildAtomEnvironment() 159 | const portalBinding = buildGuestPortalBinding(client, atomEnv, 'some-portal') 160 | 161 | await portalBinding.initialize() 162 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 163 | 164 | const editorProxy = new FakeEditorProxy('some-uri') 165 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy) 166 | assert(portalBinding.sitePositionsComponent.element.parentElement) 167 | 168 | const localPaneItem1 = await atomEnv.workspace.open() 169 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 170 | 171 | localPaneItem1.destroy() 172 | assert(portalBinding.sitePositionsComponent.element.parentElement) 173 | 174 | const localPaneItem2 = await atomEnv.workspace.open() 175 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 176 | 177 | editorProxy.dispose() 178 | localPaneItem2.destroy() 179 | assert.equal(atomEnv.workspace.getActivePaneItem(), null) 180 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 181 | }) 182 | 183 | function buildGuestPortalBinding (client, atomEnv, portalId) { 184 | return new GuestPortalBinding({ 185 | client, 186 | portalId, 187 | notificationManager: atomEnv.notifications, 188 | workspace: atomEnv.workspace 189 | }) 190 | } 191 | }) 192 | -------------------------------------------------------------------------------- /test/helpers/atom-environments.js: -------------------------------------------------------------------------------- 1 | const environments = [] 2 | 3 | exports.buildAtomEnvironment = function buildAtomEnvironment () { 4 | const env = global.buildAtomEnvironment({enablePersistence: false}) 5 | environments.push(env) 6 | return env 7 | } 8 | 9 | exports.destroyAtomEnvironments = function destroyAtomEnvironments () { 10 | const destroyPromises = environments.map((e) => e.destroy()) 11 | environments.length = 0 12 | return Promise.all(destroyPromises) 13 | } 14 | -------------------------------------------------------------------------------- /test/helpers/condition.js: -------------------------------------------------------------------------------- 1 | module.exports = function condition (fn) { 2 | const timeoutError = new Error('Condition timed out: ' + fn.toString()) 3 | Error.captureStackTrace(timeoutError, condition) 4 | 5 | return new Promise((resolve, reject) => { 6 | const intervalId = global.setInterval(async () => { 7 | let result = fn() 8 | if (result instanceof Promise) { 9 | result = await result 10 | } 11 | 12 | if (result) { 13 | global.clearTimeout(timeout) 14 | global.clearInterval(intervalId) 15 | resolve() 16 | } 17 | }, 5) 18 | 19 | const timeout = global.setTimeout(() => { 20 | global.clearInterval(intervalId) 21 | reject(timeoutError) 22 | }, 500) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /test/helpers/editor-helpers.js: -------------------------------------------------------------------------------- 1 | exports.setEditorHeightInLines = async function setEditorHeightInLines (editor, lines) { 2 | editor.element.style.height = editor.getLineHeightInPixels() * lines + 'px' 3 | return editor.component.getNextUpdatePromise() 4 | } 5 | 6 | exports.setEditorWidthInChars = async function setEditorWidthInChars (editor, chars) { 7 | editor.element.style.width = 8 | editor.component.getGutterContainerWidth() + 9 | chars * editor.getDefaultCharWidth() + 10 | 'px' 11 | return editor.component.getNextUpdatePromise() 12 | } 13 | 14 | exports.setEditorScrollTopInLines = async function setEditorScrollTopInLines (editor, lines) { 15 | editor.element.setScrollTop(editor.getLineHeightInPixels() * lines) 16 | return editor.component.getNextUpdatePromise() 17 | } 18 | 19 | exports.setEditorScrollLeftInChars = async function setEditorScrollLeftInChars (editor, chars) { 20 | editor.element.setScrollLeft(editor.getDefaultCharWidth() * chars) 21 | return editor.component.getNextUpdatePromise() 22 | } 23 | -------------------------------------------------------------------------------- /test/helpers/fake-authentication-provider.js: -------------------------------------------------------------------------------- 1 | const {Disposable} = require('atom') 2 | 3 | module.exports = 4 | class FakeAuthenticationProvider { 5 | constructor ({notificationManager}) { 6 | this.notificationManager = notificationManager 7 | } 8 | 9 | onDidChange (callback) { 10 | return new Disposable(callback) 11 | } 12 | 13 | async signIn (token) { 14 | return true 15 | } 16 | 17 | isSigningIn () { 18 | return false 19 | } 20 | 21 | async signOut () {} 22 | 23 | dispose () {} 24 | } 25 | -------------------------------------------------------------------------------- /test/helpers/fake-buffer-proxy.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {Point} = require('atom') 3 | 4 | let nextBufferProxyId = 1 5 | 6 | module.exports = 7 | class FakeBufferProxy { 8 | constructor ({delegate, text, uri} = {}) { 9 | this.id = nextBufferProxyId++ 10 | this.delegate = delegate 11 | this.disposed = false 12 | this.text = text || '' 13 | this.uri = uri || `uri-${this.id}` 14 | this.saveRequestCount = 0 15 | } 16 | 17 | setDelegate (delegate) { 18 | this.delegate = delegate 19 | } 20 | 21 | dispose () { 22 | this.disposed = true 23 | } 24 | 25 | getHistory () { 26 | return {undoStack: [], redoStack: [], nextCheckpointId: 1} 27 | } 28 | 29 | setTextInRange (oldStart, oldEnd, newText) { 30 | const oldStartIndex = characterIndexForPosition(this.text, oldStart) 31 | const oldEndIndex = characterIndexForPosition(this.text, oldEnd) 32 | this.text = this.text.slice(0, oldStartIndex) + newText + this.text.slice(oldEndIndex) 33 | } 34 | 35 | simulateRemoteTextUpdate (changes) { 36 | assert(changes.length > 0, 'Must update text with at least one change') 37 | 38 | for (let i = changes.length - 1; i >= 0; i--) { 39 | const {oldStart, oldEnd, newText} = changes[i] 40 | this.setTextInRange(oldStart, oldEnd, newText) 41 | } 42 | 43 | this.delegate.updateText(changes) 44 | } 45 | 46 | simulateRemoteURIChange (newURI) { 47 | this.uri = newURI 48 | this.delegate.didChangeURI(newURI) 49 | } 50 | 51 | createCheckpoint () { 52 | return 1 53 | } 54 | 55 | groupChangesSinceCheckpoint () { 56 | return [] 57 | } 58 | 59 | groupLastChanges () { 60 | return true 61 | } 62 | 63 | requestSave () { 64 | this.saveRequestCount++ 65 | } 66 | 67 | setURI (newUri) { 68 | this.uri = newUri 69 | } 70 | 71 | applyGroupingInterval () {} 72 | 73 | revertToCheckpoint () {} 74 | } 75 | 76 | function characterIndexForPosition (text, target) { 77 | target = Point.fromObject(target) 78 | const position = Point(0, 0) 79 | let index = 0 80 | while (position.compare(target) < 0 && index < text.length) { 81 | if (text[index] === '\n') { 82 | position.row++ 83 | position.column = 0 84 | } else { 85 | position.column++ 86 | } 87 | 88 | index++ 89 | } 90 | 91 | return index 92 | } 93 | -------------------------------------------------------------------------------- /test/helpers/fake-clipboard.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class FakeClipboard { 3 | constructor () { 4 | this.text = null 5 | } 6 | 7 | read () { 8 | return this.text 9 | } 10 | 11 | write (text) { 12 | this.text = text 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/helpers/fake-command-registry.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class FakeCommandRegistry { 3 | constructor () { 4 | this.items = new Set() 5 | } 6 | 7 | add (elem, commands) { 8 | this.items.add({ elem, commands }) 9 | return this 10 | } 11 | 12 | dispose () { 13 | this.items.forEach(({elem, command}) => { 14 | elem.dispose() 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/helpers/fake-credential-cache.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class FakeCredentialCache { 3 | constructor () { 4 | this.credentialsByKey = new Map() 5 | } 6 | 7 | async get (key) { 8 | return this.credentialsByKey.get(key) 9 | } 10 | 11 | async set (key, password) { 12 | this.credentialsByKey.set(key, password) 13 | } 14 | 15 | async delete (key) { 16 | this.credentialsByKey.delete(key) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/helpers/fake-editor-proxy.js: -------------------------------------------------------------------------------- 1 | const FakeBufferProxy = require('./fake-buffer-proxy') 2 | 3 | let nextEditorProxyId = 1 4 | 5 | module.exports = 6 | class FakeEditorProxy { 7 | constructor (uri) { 8 | this.id = nextEditorProxyId++ 9 | this.bufferProxy = new FakeBufferProxy({uri}) 10 | } 11 | 12 | dispose () { 13 | if (this.delegate) this.delegate.dispose() 14 | } 15 | 16 | follow () {} 17 | 18 | didScroll () {} 19 | 20 | setDelegate (delegate) { 21 | this.delegate = delegate 22 | } 23 | 24 | updateSelections () {} 25 | } 26 | -------------------------------------------------------------------------------- /test/helpers/fake-notification-manager.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class FakeNotificationManager { 3 | constructor () { 4 | this.errorCount = 0 5 | } 6 | 7 | addInfo () {} 8 | 9 | addSuccess () {} 10 | 11 | addError () { 12 | this.errorCount++ 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/helpers/fake-portal.js: -------------------------------------------------------------------------------- 1 | const {FollowState} = require('@atom/teletype-client') 2 | const FakeBufferProxy = require('./fake-buffer-proxy') 3 | const FakeEditorProxy = require('./fake-editor-proxy') 4 | 5 | module.exports = 6 | class FakePortal { 7 | constructor ({siteId} = {}) { 8 | this.siteId = siteId 9 | this.activeEditorProxyChangeCount = 0 10 | } 11 | 12 | dispose () {} 13 | 14 | createBufferProxy () { 15 | return new FakeBufferProxy() 16 | } 17 | 18 | createEditorProxy () { 19 | return new FakeEditorProxy() 20 | } 21 | 22 | follow (siteId) { 23 | this.followedSiteId = siteId 24 | this.setFollowState(FollowState.RETRACTED) 25 | } 26 | 27 | unfollow () { 28 | this.followedSiteId = null 29 | this.setFollowState(FollowState.DISCONNECTED) 30 | } 31 | 32 | setFollowState (followState) { 33 | this.followState = followState 34 | } 35 | 36 | resolveFollowState () { 37 | return this.followState 38 | } 39 | 40 | getFollowedSiteId () { 41 | return this.followedSiteId 42 | } 43 | 44 | activateEditorProxy (editorProxy) { 45 | this.activeEditorProxy = editorProxy 46 | this.activeEditorProxyChangeCount++ 47 | } 48 | 49 | getActiveEditorProxy () { 50 | return this.activeEditorProxy 51 | } 52 | 53 | setDelegate (delegate) { 54 | this.delegate = delegate 55 | } 56 | 57 | getSiteIdentity (siteId) { 58 | return {login: 'site-' + siteId} 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/helpers/fake-status-bar.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class FakeStatusBar { 3 | constructor () { 4 | this.rightTiles = [] 5 | } 6 | 7 | getRightTiles () { 8 | return this.rightTiles 9 | } 10 | 11 | addRightTile (tile) { 12 | this.rightTiles.push(tile) 13 | return { 14 | getItem: () => tile.item, 15 | destroy: () => { 16 | const index = this.rightTiles.indexOf(tile) 17 | this.rightTiles.splice(index, 1) 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/helpers/fake-workspace.js: -------------------------------------------------------------------------------- 1 | const {Disposable} = require('atom') 2 | 3 | module.exports = 4 | class FakeWorkspace { 5 | async open () {} 6 | 7 | getCenter () { 8 | return { 9 | paneContainer: { 10 | getElement () { 11 | return document.createElement('div') 12 | } 13 | } 14 | } 15 | } 16 | 17 | getElement () { 18 | return document.createElement('div') 19 | } 20 | 21 | observeTextEditors () { 22 | return new Disposable(() => {}) 23 | } 24 | 25 | observeActiveTextEditor () { 26 | return new Disposable(() => {}) 27 | } 28 | 29 | onDidDestroyPaneItem () { 30 | return new Disposable(() => {}) 31 | } 32 | 33 | onDidChangeActivePaneItem () { 34 | return new Disposable(() => {}) 35 | } 36 | 37 | paneForItem () {} 38 | } 39 | -------------------------------------------------------------------------------- /test/helpers/ui-helpers.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | // Load package style sheets for the given environment so that the package's 4 | // UI elements are styled correctly. 5 | exports.loadPackageStyleSheets = function (environment) { 6 | const packageStyleSheetPath = path.join(__dirname, '..', '..', 'styles', 'teletype.less') 7 | const compiledStyleSheet = environment.themes.loadStylesheet(packageStyleSheetPath) 8 | environment.styles.addStyleSheet(compiledStyleSheet) 9 | } 10 | -------------------------------------------------------------------------------- /test/host-portal-binding.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {Emitter, TextEditor} = require('atom') 3 | const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') 4 | const {FollowState, TeletypeClient} = require('@atom/teletype-client') 5 | const HostPortalBinding = require('../lib/host-portal-binding') 6 | const FakeClipboard = require('./helpers/fake-clipboard') 7 | const FakePortal = require('./helpers/fake-portal') 8 | 9 | suite('HostPortalBinding', () => { 10 | teardown(async () => { 11 | await destroyAtomEnvironments() 12 | }) 13 | 14 | test('handling an unexpected error when joining a portal', async () => { 15 | const client = new TeletypeClient({pubSubGateway: {}}) 16 | client.createPortal = function () { 17 | throw new Error('It broke!') 18 | } 19 | const atomEnv = buildAtomEnvironment() 20 | const portalBinding = buildHostPortalBinding(client, atomEnv) 21 | 22 | const result = await portalBinding.initialize() 23 | assert.equal(result, false) 24 | 25 | assert.equal(atomEnv.notifications.getNotifications().length, 1) 26 | const {message, options} = atomEnv.notifications.getNotifications()[0] 27 | assert.equal(message, 'Failed to share portal') 28 | assert(options.description.includes('It broke!')) 29 | }) 30 | 31 | test('showing notifications when sites join or leave', async () => { 32 | const portal = new FakePortal() 33 | const client = new TeletypeClient({pubSubGateway: {}}) 34 | client.createPortal = function () { 35 | return portal 36 | } 37 | const atomEnv = buildAtomEnvironment() 38 | const portalBinding = buildHostPortalBinding(client, atomEnv) 39 | await portalBinding.initialize() 40 | 41 | atomEnv.notifications.clear() 42 | portal.delegate.siteDidJoin(2) 43 | assert.equal(atomEnv.notifications.getNotifications().length, 1) 44 | assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-2')) 45 | 46 | atomEnv.notifications.clear() 47 | portal.delegate.siteDidLeave(3) 48 | assert.equal(atomEnv.notifications.getNotifications().length, 1) 49 | assert(atomEnv.notifications.getNotifications()[0].message.includes('@site-3')) 50 | }) 51 | 52 | test('switching the active editor to a remote editor that had been moved into a non-active pane', async () => { 53 | const client = new TeletypeClient({pubSubGateway: {}}) 54 | const portal = new FakePortal() 55 | client.createPortal = () => portal 56 | const atomEnv = buildAtomEnvironment() 57 | const portalBinding = buildHostPortalBinding(client, atomEnv) 58 | await portalBinding.initialize() 59 | 60 | await atomEnv.workspace.open() 61 | await atomEnv.workspace.open() 62 | const editorProxy2 = portal.getActiveEditorProxy() 63 | 64 | const leftPane = atomEnv.workspace.getActivePane() 65 | const rightPane = leftPane.splitRight({moveActiveItem: true}) 66 | assert.equal(leftPane.getItems().length, 1) 67 | assert.equal(rightPane.getItems().length, 1) 68 | assert.equal(atomEnv.workspace.getActivePane(), rightPane) 69 | 70 | leftPane.activate() 71 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy2) 72 | assert.equal(leftPane.getItems().length, 1) 73 | assert.equal(rightPane.getItems().length, 1) 74 | assert.equal(atomEnv.workspace.getActivePane(), rightPane) 75 | }) 76 | 77 | test('gracefully handles attempt to update tether for destroyed editors', async () => { 78 | const client = new TeletypeClient({pubSubGateway: {}}) 79 | const portal = new FakePortal() 80 | client.createPortal = () => portal 81 | const atomEnv = buildAtomEnvironment() 82 | const portalBinding = buildHostPortalBinding(client, atomEnv) 83 | await portalBinding.initialize() 84 | 85 | const editor = await atomEnv.workspace.open() 86 | editor.getBuffer().setTextInRange('Lorem ipsum dolor', [[0, 0], [0, 0]]) 87 | const editorProxy = portal.getActiveEditorProxy() 88 | 89 | await portalBinding.updateTether(FollowState.RETRACTED, editorProxy, {row: 0, column: 3}) 90 | assert.deepEqual(editor.getCursorBufferPosition(), {row: 0, column: 3}) 91 | 92 | editor.destroy() 93 | await portalBinding.updateTether(FollowState.DISCONNECTED, editorProxy, {row: 0, column: 5}) 94 | }) 95 | 96 | test('toggling site position components visibility when switching between shared and non-shared pane items', async () => { 97 | const client = new TeletypeClient({pubSubGateway: {}}) 98 | const portal = new FakePortal() 99 | client.createPortal = () => portal 100 | const atomEnv = buildAtomEnvironment() 101 | const portalBinding = buildHostPortalBinding(client, atomEnv) 102 | 103 | const localEditor1 = await atomEnv.workspace.open() 104 | await portalBinding.initialize() 105 | assert(portalBinding.sitePositionsComponent.element.parentElement) 106 | 107 | const localNonEditor = await atomEnv.workspace.open(new FakePaneItem()) 108 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 109 | 110 | const localEditor2 = await atomEnv.workspace.open() 111 | assert(portalBinding.sitePositionsComponent.element.parentElement) 112 | 113 | const remoteEditor = new TextEditor() 114 | remoteEditor.isRemote = true 115 | await atomEnv.workspace.open(remoteEditor) 116 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 117 | 118 | await atomEnv.workspace.open(localEditor2) 119 | assert(portalBinding.sitePositionsComponent.element.parentElement) 120 | 121 | remoteEditor.destroy() 122 | localEditor1.destroy() 123 | localEditor2.destroy() 124 | localNonEditor.destroy() 125 | assert(!portalBinding.sitePositionsComponent.element.parentElement) 126 | }) 127 | 128 | function buildHostPortalBinding (client, atomEnv) { 129 | return new HostPortalBinding({ 130 | client, 131 | notificationManager: atomEnv.notifications, 132 | workspace: atomEnv.workspace, 133 | clipboard: new FakeClipboard() 134 | }) 135 | } 136 | }) 137 | 138 | class FakePaneItem { 139 | constructor () { 140 | this.element = document.createElement('div') 141 | this.emitter = new Emitter() 142 | } 143 | 144 | destroy () { 145 | this.emitter.emit('did-destroy') 146 | } 147 | 148 | onDidDestroy (callback) { 149 | return this.emitter.on('did-destroy', callback) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /test/portal-binding-manager.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') 3 | const PortalBindingManager = require('../lib/portal-binding-manager') 4 | 5 | suite('PortalBindingManager', () => { 6 | teardown(async () => { 7 | await destroyAtomEnvironments() 8 | }) 9 | 10 | suite('host portal binding', () => { 11 | test('idempotently creating the host portal binding', async () => { 12 | const manager = buildPortalBindingManager() 13 | 14 | const portalBinding1Promise = manager.createHostPortalBinding() 15 | assert.equal(portalBinding1Promise, manager.createHostPortalBinding()) 16 | 17 | manager.client.resolveLastCreatePortalPromise(buildPortal()) 18 | const portalBinding1 = await portalBinding1Promise 19 | assert.equal(manager.createHostPortalBinding(), portalBinding1Promise) 20 | assert.equal(manager.getHostPortalBinding(), portalBinding1Promise) 21 | 22 | portalBinding1.close() 23 | const portalBinding2Promise = manager.createHostPortalBinding() 24 | assert.notEqual(portalBinding2Promise, portalBinding1Promise) 25 | assert.equal(manager.createHostPortalBinding(), portalBinding2Promise) 26 | assert.equal(manager.getHostPortalBinding(), portalBinding2Promise) 27 | }) 28 | 29 | test('successfully fetching a binding after failing the first time', async () => { 30 | const manager = buildPortalBindingManager() 31 | 32 | const portalBinding1Promise = manager.createHostPortalBinding() 33 | manager.client.resolveLastCreatePortalPromise(null) 34 | assert(!await portalBinding1Promise) 35 | assert(!await manager.getHostPortalBinding()) 36 | 37 | const portalBinding2Promise = manager.createHostPortalBinding() 38 | manager.client.resolveLastCreatePortalPromise(buildPortal()) 39 | assert(await portalBinding2Promise) 40 | assert(await manager.getHostPortalBinding()) 41 | }) 42 | }) 43 | 44 | suite('guest portal bindings', () => { 45 | test('idempotently creating guest portal bindings', () => { 46 | const manager = buildPortalBindingManager() 47 | 48 | const portal1BindingPromise1 = manager.createGuestPortalBinding('1') 49 | const portal1BindingPromise2 = manager.createGuestPortalBinding('1') 50 | const portal2BindingPromise1 = manager.createGuestPortalBinding('2') 51 | assert.equal(portal1BindingPromise1, portal1BindingPromise2) 52 | assert.notEqual(portal1BindingPromise1, portal2BindingPromise1) 53 | }) 54 | 55 | test('successfully fetching a binding after failing the first time', async () => { 56 | const manager = buildPortalBindingManager() 57 | 58 | const portalBinding1Promise1 = manager.createGuestPortalBinding('1') 59 | manager.client.resolveLastJoinPortalPromise(null) 60 | assert(!await portalBinding1Promise1) 61 | 62 | const portalBinding1Promise2 = manager.createGuestPortalBinding('1') 63 | assert.notEqual(portalBinding1Promise1, portalBinding1Promise2) 64 | 65 | manager.client.resolveLastJoinPortalPromise(buildPortal()) 66 | assert(await portalBinding1Promise2) 67 | }) 68 | 69 | suite('getGuestPortalBindings', () => { 70 | test('excludes portals that could not be joined', async () => { 71 | const manager = buildPortalBindingManager() 72 | 73 | manager.createGuestPortalBinding('1') 74 | manager.client.resolveLastJoinPortalPromise(null) 75 | const portal2BindingPromise = manager.createGuestPortalBinding('2') 76 | manager.client.resolveLastJoinPortalPromise(buildPortal()) 77 | 78 | assert.deepEqual(await manager.getGuestPortalBindings(), [await portal2BindingPromise]) 79 | }) 80 | }) 81 | }) 82 | 83 | test('adding and removing classes from the workspace element', async () => { 84 | const manager = buildPortalBindingManager() 85 | 86 | const portalBinding1Promise = manager.createGuestPortalBinding('1') 87 | manager.client.resolveLastJoinPortalPromise(buildPortal()) 88 | const portalBinding1 = await portalBinding1Promise 89 | assert(manager.workspace.element.classList.contains('teletype-Guest')) 90 | 91 | const portalBinding2Promise = manager.createGuestPortalBinding('2') 92 | manager.client.resolveLastJoinPortalPromise(buildPortal()) 93 | const portalBinding2 = await portalBinding2Promise 94 | assert(manager.workspace.element.classList.contains('teletype-Guest')) 95 | 96 | portalBinding1.leave() 97 | assert(manager.workspace.element.classList.contains('teletype-Guest')) 98 | 99 | portalBinding2.leave() 100 | assert(!manager.workspace.element.classList.contains('teletype-Guest')) 101 | }) 102 | }) 103 | 104 | function buildPortalBindingManager () { 105 | const {workspace, notifications: notificationManager} = buildAtomEnvironment() 106 | const client = { 107 | resolveLastCreatePortalPromise: null, 108 | resolveLastJoinPortalPromise: null, 109 | createPortal () { 110 | return new Promise((resolve) => { this.resolveLastCreatePortalPromise = resolve }) 111 | }, 112 | joinPortal () { 113 | return new Promise((resolve) => { this.resolveLastJoinPortalPromise = resolve }) 114 | } 115 | } 116 | return new PortalBindingManager({client, workspace, notificationManager}) 117 | } 118 | 119 | let nextPortalId = 1 120 | let nextIdentityId = 1 121 | function buildPortal ({id, login} = {}) { 122 | return { 123 | id: id != null ? id : (nextPortalId++).toString(), 124 | activateEditorProxy () {}, 125 | getSiteIdentity () { 126 | return {login: login || 'identity-' + nextIdentityId++} 127 | }, 128 | dispose () { 129 | this.delegate.dispose() 130 | }, 131 | setDelegate (delegate) { 132 | this.delegate = delegate 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/portal-list-component.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const condition = require('./helpers/condition') 3 | const FakeClipboard = require('./helpers/fake-clipboard') 4 | const {TeletypeClient} = require('@atom/teletype-client') 5 | const {startTestServer} = require('@atom/teletype-server') 6 | const PortalBindingManager = require('../lib/portal-binding-manager') 7 | const PortalListComponent = require('../lib/portal-list-component') 8 | const FakeNotificationManager = require('./helpers/fake-notification-manager') 9 | const FakeWorkspace = require('./helpers/fake-workspace') 10 | const FakeCommandRegistry = require('./helpers/fake-command-registry') 11 | 12 | suite('PortalListComponent', function () { 13 | if (process.env.CI) this.timeout(process.env.TEST_TIMEOUT_IN_MS) 14 | 15 | let testServer, portalBindingManagers 16 | 17 | suiteSetup(async function () { 18 | testServer = await startTestServer({databaseURL: 'postgres://localhost:5432/teletype-test'}) 19 | }) 20 | 21 | suiteTeardown(() => { 22 | return testServer.stop() 23 | }) 24 | 25 | setup(() => { 26 | portalBindingManagers = [] 27 | return testServer.reset() 28 | }) 29 | 30 | teardown(async () => { 31 | for (const portalBindingManager of portalBindingManagers) { 32 | await portalBindingManager.dispose() 33 | } 34 | }) 35 | 36 | test('initialization', async () => { 37 | const portalBindingManager = await buildPortalBindingManager() 38 | const component = new PortalListComponent({ 39 | portalBindingManager, 40 | commandRegistry: new FakeCommandRegistry(), 41 | localUserIdentity: {login: 'some-user'} 42 | }) 43 | assert(component.refs.initializationSpinner) 44 | assert(!component.refs.hostPortalBindingComponent) 45 | 46 | await component.initializationPromise 47 | assert(!component.refs.initializationSpinner) 48 | assert(component.refs.hostPortalBindingComponent) 49 | }) 50 | 51 | test('sharing portals', async () => { 52 | const {component, element, portalBindingManager} = await buildComponent() 53 | 54 | const {hostPortalBindingComponent} = component.refs 55 | assert(!hostPortalBindingComponent.refs.toggleShareCheckbox.checked) 56 | assert(!hostPortalBindingComponent.refs.creatingPortalSpinner) 57 | 58 | // Toggle sharing on. 59 | hostPortalBindingComponent.toggleShare() 60 | await condition(() => ( 61 | hostPortalBindingComponent.refs.toggleShareCheckbox.checked && 62 | hostPortalBindingComponent.refs.creatingPortalSpinner 63 | )) 64 | await condition(() => ( 65 | hostPortalBindingComponent.refs.toggleShareCheckbox.checked && 66 | !hostPortalBindingComponent.refs.creatingPortalSpinner 67 | )) 68 | 69 | // Simulate multiple guests joining. 70 | const {portal} = await portalBindingManager.getHostPortalBinding() 71 | 72 | const guestPortalBindingManager1 = await buildPortalBindingManager() 73 | await guestPortalBindingManager1.createGuestPortalBinding(portal.id) 74 | 75 | const guestPortalBindingManager2 = await buildPortalBindingManager() 76 | await guestPortalBindingManager2.createGuestPortalBinding(portal.id) 77 | 78 | await condition(() => queryParticipantElements(element).length === 3) 79 | assert(queryParticipantElement(element, 1)) 80 | assert(queryParticipantElement(element, 2)) 81 | assert(queryParticipantElement(element, 3)) 82 | 83 | // Toggle sharing off. 84 | hostPortalBindingComponent.toggleShare() 85 | 86 | await condition(() => queryParticipantElements(element).length === 1) 87 | assert(!hostPortalBindingComponent.refs.toggleShareCheckbox.checked) 88 | assert(!hostPortalBindingComponent.refs.creatingPortalSpinner) 89 | }) 90 | 91 | test('joining portals', async () => { 92 | const {component} = await buildComponent() 93 | const {joinPortalComponent, guestPortalBindingsContainer} = component.refs 94 | 95 | assert(joinPortalComponent.refs.joinPortalLabel) 96 | assert(!joinPortalComponent.refs.portalIdEditor) 97 | assert(!joinPortalComponent.refs.joiningSpinner) 98 | assert(!joinPortalComponent.refs.joinButton) 99 | 100 | await joinPortalComponent.showPrompt() 101 | 102 | assert(!joinPortalComponent.refs.joinPortalLabel) 103 | assert(joinPortalComponent.refs.joinButton.disabled) 104 | assert(joinPortalComponent.refs.portalIdEditor) 105 | assert(!joinPortalComponent.refs.joiningSpinner) 106 | 107 | // Attempt to join without inserting a portal URI. 108 | await joinPortalComponent.joinPortal() 109 | 110 | assert.equal(component.props.notificationManager.errorCount, 1) 111 | assert(!joinPortalComponent.refs.joinPortalLabel) 112 | assert(!joinPortalComponent.refs.joiningSpinner) 113 | assert(joinPortalComponent.refs.portalIdEditor) 114 | assert(joinPortalComponent.refs.joinButton.disabled) 115 | 116 | // Insert an invalid portal URI. 117 | joinPortalComponent.refs.portalIdEditor.setText('atom://invalid-portal-id') 118 | assert(joinPortalComponent.refs.joinButton.disabled) 119 | 120 | await joinPortalComponent.joinPortal() 121 | 122 | assert.equal(component.props.notificationManager.errorCount, 2) 123 | assert(!joinPortalComponent.refs.joinPortalLabel) 124 | assert(!joinPortalComponent.refs.joiningSpinner) 125 | assert(joinPortalComponent.refs.portalIdEditor) 126 | 127 | // Insert a valid portal URI. 128 | const hostPortalBindingManager = await buildPortalBindingManager() 129 | const hostPortalBinding = await hostPortalBindingManager.createHostPortalBinding() 130 | 131 | joinPortalComponent.refs.portalIdEditor.setText(hostPortalBinding.uri) 132 | assert(!joinPortalComponent.refs.joinButton.disabled) 133 | 134 | joinPortalComponent.joinPortal() 135 | 136 | await condition(() => ( 137 | !joinPortalComponent.refs.joinPortalLabel && 138 | joinPortalComponent.refs.joiningSpinner && 139 | !joinPortalComponent.refs.portalIdEditor 140 | )) 141 | await condition(() => ( 142 | joinPortalComponent.refs.joinPortalLabel && 143 | !joinPortalComponent.refs.joiningSpinner && 144 | !joinPortalComponent.refs.portalIdEditor 145 | )) 146 | await condition(() => queryParticipantElements(guestPortalBindingsContainer).length === 2) 147 | assert(queryParticipantElement(guestPortalBindingsContainer, 1)) 148 | assert(queryParticipantElement(guestPortalBindingsContainer, 2)) 149 | 150 | // Insert a valid portal URI but with leading and trailing whitespace. 151 | await joinPortalComponent.showPrompt() 152 | 153 | joinPortalComponent.refs.portalIdEditor.setText('\t ' + hostPortalBinding.uri + '\n\r\n') 154 | joinPortalComponent.joinPortal() 155 | 156 | await condition(() => ( 157 | !joinPortalComponent.refs.joinPortalLabel && 158 | joinPortalComponent.refs.joiningSpinner && 159 | !joinPortalComponent.refs.portalIdEditor 160 | )) 161 | await condition(() => ( 162 | joinPortalComponent.refs.joinPortalLabel && 163 | !joinPortalComponent.refs.joiningSpinner && 164 | !joinPortalComponent.refs.portalIdEditor 165 | )) 166 | await condition(() => queryParticipantElements(guestPortalBindingsContainer).length === 2) 167 | assert(queryParticipantElement(guestPortalBindingsContainer, 1)) 168 | assert(queryParticipantElement(guestPortalBindingsContainer, 2)) 169 | 170 | // Insert a valid portal ID. 171 | await joinPortalComponent.showPrompt() 172 | 173 | joinPortalComponent.refs.portalIdEditor.setText('\t ' + hostPortalBinding.portal.id + '\n\r\n') 174 | joinPortalComponent.joinPortal() 175 | 176 | await condition(() => ( 177 | !joinPortalComponent.refs.joinPortalLabel && 178 | joinPortalComponent.refs.joiningSpinner && 179 | !joinPortalComponent.refs.portalIdEditor 180 | )) 181 | await condition(() => ( 182 | joinPortalComponent.refs.joinPortalLabel && 183 | !joinPortalComponent.refs.joiningSpinner && 184 | !joinPortalComponent.refs.portalIdEditor 185 | )) 186 | await condition(() => queryParticipantElements(guestPortalBindingsContainer).length === 2) 187 | assert(queryParticipantElement(guestPortalBindingsContainer, 1)) 188 | assert(queryParticipantElement(guestPortalBindingsContainer, 2)) 189 | 190 | // Simulate another guest joining the portal. 191 | const newGuestPortalBindingManager = await buildPortalBindingManager() 192 | await newGuestPortalBindingManager.createGuestPortalBinding(hostPortalBinding.portal.id) 193 | 194 | await condition(() => queryParticipantElements(guestPortalBindingsContainer).length === 3) 195 | assert(queryParticipantElement(guestPortalBindingsContainer, 1)) 196 | assert(queryParticipantElement(guestPortalBindingsContainer, 2)) 197 | assert(queryParticipantElement(guestPortalBindingsContainer, 3)) 198 | }) 199 | 200 | test('prefilling portal URI from clipboard', async () => { 201 | const {component} = await buildComponent() 202 | const {clipboard} = component.props 203 | const {joinPortalComponent} = component.refs 204 | 205 | // Clipboard containing a portal URI 206 | clipboard.write('atom://teletype/portal/bc282ad8-7643-42cb-80ca-c243771a618f') 207 | await joinPortalComponent.showPrompt() 208 | 209 | assert.equal(joinPortalComponent.refs.portalIdEditor.getText(), 'atom://teletype/portal/bc282ad8-7643-42cb-80ca-c243771a618f') 210 | 211 | // Clipboard containing a portal URI with surrounding whitespace 212 | await joinPortalComponent.hidePrompt() 213 | clipboard.write('\tatom://teletype/portal/e40fa1b5-8144-4d09-9dff-c26e7b10b366 \n') 214 | await joinPortalComponent.showPrompt() 215 | 216 | assert.equal(joinPortalComponent.refs.portalIdEditor.getText(), 'atom://teletype/portal/e40fa1b5-8144-4d09-9dff-c26e7b10b366') 217 | 218 | // Clipboard containing something that is NOT a portal URI 219 | await joinPortalComponent.hidePrompt() 220 | clipboard.write('atom://not-a-portal-uri') 221 | await joinPortalComponent.showPrompt() 222 | 223 | assert.equal(joinPortalComponent.refs.portalIdEditor.getText(), '') 224 | }) 225 | 226 | function queryParticipantElement (element, siteId) { 227 | const participants = element.querySelectorAll('.PortalParticipants-site-' + siteId) 228 | assert.equal(participants.length, 1) 229 | return participants[0] 230 | } 231 | 232 | function queryParticipantElements (element) { 233 | return element.querySelectorAll('.PortalParticipants-participant') 234 | } 235 | 236 | async function buildComponent () { 237 | const notificationManager = new FakeNotificationManager() 238 | const portalBindingManager = await buildPortalBindingManager({notificationManager}) 239 | const component = new PortalListComponent({ 240 | portalBindingManager, 241 | notificationManager, 242 | clipboard: new FakeClipboard(), 243 | commandRegistry: new FakeCommandRegistry(), 244 | localUserIdentity: portalBindingManager.client.getLocalUserIdentity() 245 | }) 246 | await component.initializationPromise 247 | 248 | return {component, element: component.element, portalBindingManager} 249 | } 250 | 251 | async function buildPortalBindingManager (options = {}) { 252 | const client = new TeletypeClient({ 253 | baseURL: testServer.address, 254 | pubSubGateway: testServer.pubSubGateway 255 | }) 256 | await client.initialize() 257 | await client.signIn('some-token') 258 | 259 | const portalBindingManager = new PortalBindingManager({ 260 | client, 261 | workspace: new FakeWorkspace(), 262 | notificationManager: options.notificationManager || new FakeNotificationManager() 263 | }) 264 | portalBindingManagers.push(portalBindingManager) 265 | return portalBindingManager 266 | } 267 | }) 268 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const {createRunner} = require('atom-mocha-test-runner') 2 | const {ipcRenderer} = require('electron') 3 | ipcRenderer.setMaxListeners(15) 4 | 5 | module.exports = createRunner({}, function (mocha) { 6 | mocha.ui('tdd') 7 | }) 8 | -------------------------------------------------------------------------------- /test/sign-in-component.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const FakeAuthenticationProvider = require('./helpers/fake-authentication-provider') 3 | const SignInComponent = require('../lib/sign-in-component') 4 | const FakeNotificationManager = require('./helpers/fake-notification-manager') 5 | const FakeCommandRegistry = require('./helpers/fake-command-registry') 6 | 7 | suite('SignInComponent', function () { 8 | test('has correct fields', () => { 9 | const component = buildComponent() 10 | assert(component.refs.editor) 11 | assert(component.refs.loginButton) 12 | assert(!component.refs.errorMessage) 13 | }) 14 | 15 | test('disables button when empty token specified', () => { 16 | { 17 | // It should be disabled by default 18 | const component = buildComponent() 19 | assert(component.refs.loginButton.disabled) 20 | } 21 | 22 | { 23 | // Whitespace should also leave the button disabled 24 | const component = buildComponent() 25 | component.refs.editor.setText(' ') 26 | assert(component.refs.loginButton.disabled) 27 | } 28 | 29 | { 30 | // It should be disabled when set to an empty string 31 | const component = buildComponent() 32 | component.refs.editor.setText('') 33 | assert(component.refs.loginButton.disabled) 34 | } 35 | }) 36 | 37 | test('enables button when non-empty token specified', () => { 38 | const component = buildComponent() 39 | component.refs.editor.setText('some-token') 40 | assert(!component.refs.loginButton.disabled) 41 | }) 42 | 43 | test('reports errors attempting to sign in', async () => { 44 | { 45 | const component = buildComponent() 46 | const {authenticationProvider} = component.props 47 | const notifications = authenticationProvider.notificationManager 48 | 49 | authenticationProvider.signIn = (token) => { 50 | notifications.addError() 51 | return false 52 | } 53 | component.refs.editor.setText('some-token') 54 | await component.signIn() 55 | 56 | // It should display an error message when the login attempt fails 57 | assert.equal(notifications.errorCount, 1) 58 | } 59 | 60 | { 61 | const component = buildComponent() 62 | 63 | await component.signIn() 64 | 65 | // It should show an error message about an invalid token 66 | assert(component.refs.errorMessage) 67 | assert.equal(component.refs.errorMessage.innerHTML, 'That token does not appear to be valid.') 68 | } 69 | }) 70 | 71 | function buildComponent () { 72 | return new SignInComponent({ 73 | commandRegistry: new FakeCommandRegistry(), 74 | authenticationProvider: new FakeAuthenticationProvider({ 75 | notificationManager: new FakeNotificationManager() 76 | }) 77 | }) 78 | } 79 | }) 80 | -------------------------------------------------------------------------------- /test/site-positions-component.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const {buildAtomEnvironment, destroyAtomEnvironments} = require('./helpers/atom-environments') 5 | const {TextEditor, TextBuffer} = require('atom') 6 | const SAMPLE_TEXT = fs.readFileSync(path.join(__dirname, 'fixtures', 'sample.js'), 'utf8') 7 | const FakePortal = require('./helpers/fake-portal') 8 | const FakeEditorProxy = require('./helpers/fake-editor-proxy') 9 | const SitePositionsComponent = require('../lib/site-positions-component') 10 | const {FollowState} = require('@atom/teletype-client') 11 | 12 | suite('SitePositionsComponent', () => { 13 | teardown(async () => { 14 | await destroyAtomEnvironments() 15 | }) 16 | 17 | test('showing and hiding the component', async () => { 18 | const {workspace} = buildAtomEnvironment() 19 | const component = new SitePositionsComponent({ 20 | workspace, 21 | portal: {}, 22 | editorBindingForEditorProxy: () => {} 23 | }) 24 | 25 | const element1 = document.createElement('div') 26 | component.show(element1) 27 | assert(element1.contains(component.element)) 28 | 29 | const element2 = document.createElement('div') 30 | component.show(element2) 31 | assert(element2.contains(component.element)) 32 | 33 | component.hide() 34 | assert(!component.element.parentElement) 35 | }) 36 | 37 | test('rendering site position avatars', async () => { 38 | const {workspace} = buildAtomEnvironment() 39 | const portal = new FakePortal({siteId: 1}) 40 | const component = new SitePositionsComponent({workspace, portal}) 41 | 42 | const editorProxy1 = new FakeEditorProxy('editor-1') 43 | const editor1 = new TextEditor({buffer: new TextBuffer(SAMPLE_TEXT)}) 44 | 45 | const editorProxy2 = new FakeEditorProxy('editor-2') 46 | const editor2 = new TextEditor({buffer: new TextBuffer(SAMPLE_TEXT)}) // eslint-disable-line no-unused-vars 47 | 48 | const element = component.element 49 | 50 | await workspace.open(editor1) 51 | 52 | const positionsBySiteId = { 53 | // the local user 54 | 1: {editorProxy: editorProxy1, position: {row: 0, column: 0}, followState: FollowState.DISCONNECTED}, 55 | 56 | // a collaborator in the same editor, with a disconnected tether 57 | 2: {editorProxy: editorProxy1, position: {row: 0, column: 0}, followState: FollowState.DISCONNECTED}, 58 | 59 | // a collaborator in the same editor, with an extended tether 60 | 3: {editorProxy: editorProxy1, position: {row: 0, column: 0}, followState: FollowState.EXTENDED}, 61 | 62 | // a collaborator in the same editor, with a retracted tether 63 | 4: {editorProxy: editorProxy1, position: {row: 0, column: 0}, followState: FollowState.RETRACTED}, 64 | 65 | // a collaborator in a different editor 66 | 5: {editorProxy: editorProxy2, position: {row: 0, column: 0}, followState: FollowState.DISCONNECTED}, 67 | 68 | // a collaborator in a non-portal pane item 69 | 6: {editorProxy: null, position: null, followState: null} 70 | } 71 | await component.update({positionsBySiteId}) 72 | 73 | assert(!element.querySelector('.SitePositionsComponent-site.site-1')) 74 | assert(element.querySelector('.SitePositionsComponent-site.site-2.viewing-current-editor.color--site-2')) 75 | assert(element.querySelector('.SitePositionsComponent-site.site-3.viewing-current-editor.color--site-3')) 76 | assert(element.querySelector('.SitePositionsComponent-site.site-4.viewing-current-editor:not(.color--site-4)')) 77 | assert(element.querySelector('.SitePositionsComponent-site.site-5.viewing-other-editor:not(.color--site-5)')) 78 | assert(element.querySelector('.SitePositionsComponent-site.site-6.viewing-non-portal-item:not(.color--site-6)')) 79 | }) 80 | 81 | test('following and unfollowing a site', () => { 82 | const {workspace} = buildAtomEnvironment() 83 | const portal = new FakePortal({siteId: 1}) 84 | const component = new SitePositionsComponent({workspace, portal}) 85 | 86 | // Selecting a site will follow them. 87 | component.onSelectSiteId(42) 88 | assert.equal(component.props.portal.getFollowedSiteId(), 42) 89 | 90 | // Selecting the same site again will unfollow them. 91 | component.onSelectSiteId(42) 92 | assert.equal(component.props.portal.getFollowedSiteId(), null) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /test/teletype-service.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const TeletypeService = require('../lib/teletype-service') 3 | 4 | suite('TeletypeService', function () { 5 | suite('getRemoteEditors()', function () { 6 | test('returns an empty array when PortalBindingManager is unavailable', async () => { 7 | const teletypePackage = { 8 | getPortalBindingManager: () => {} 9 | } 10 | const service = new TeletypeService({teletypePackage}) 11 | 12 | assert.deepEqual(await service.getRemoteEditors(), []) 13 | }) 14 | }) 15 | }) 16 | --------------------------------------------------------------------------------