├── .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 | 
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 | 
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 | 
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 | 
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 | 
66 |
67 | - At any time, you can right-click a person in your list of trusted collaborators and remove them:
68 |
69 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------