├── .gitignore ├── lib ├── utils │ └── requireUncached.coffee ├── views │ ├── share-status-bar.coffee │ └── share-setup.coffee ├── firepad-tab-icon.coffee ├── firepad-share.coffee └── firepad.coffee ├── menus └── firepad.cson ├── styles └── firepad-tab-icon.less ├── keymaps └── firepad.cson ├── coffeelint.json ├── package.json ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules 4 | -------------------------------------------------------------------------------- /lib/utils/requireUncached.coffee: -------------------------------------------------------------------------------- 1 | # http://stackoverflow.com/questions/9210542/node-js-require-cache-possible-to-invalidate 2 | module.exports = (module) -> 3 | delete require.cache[require.resolve(module)] 4 | require(module) 5 | -------------------------------------------------------------------------------- /menus/firepad.cson: -------------------------------------------------------------------------------- 1 | 'menu': [ 2 | { 3 | 'label': 'Packages' 4 | 'submenu': [ 5 | 'label': 'Firepad' 6 | 'submenu': [ 7 | { 'label': 'Share the file', 'command': 'firepad:share' } 8 | { 'label': 'Unshare th file', 'command': 'firepad:unshare' } 9 | { 'label': 'Copy the ID', 'command': 'firepad:copyid' } 10 | ] 11 | ] 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /styles/firepad-tab-icon.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | @import "syntax-variables"; 3 | 4 | .tab{ 5 | [data-name]:before { 6 | margin-right: 5px; 7 | position: relative; 8 | font-family: 'Octicons Regular'; 9 | font-weight: normal; 10 | font-style: normal; 11 | font-size: 15px; 12 | display: inline-block; 13 | text-align: center; 14 | text-decoration: none; 15 | -webkit-font-smoothing: antialiased; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/views/share-status-bar.coffee: -------------------------------------------------------------------------------- 1 | {View} = require 'atom-space-pen-views' 2 | 3 | module.exports = 4 | class ShareStatusBarView extends View 5 | @content: -> 6 | @div class: 'inline-block text-warning', tabindex: -1, => 7 | @span outlet: 'shareInfo' 8 | 9 | initialize: -> 10 | 11 | destroy: -> 12 | @detach() 13 | 14 | show: (shareIdentifier) -> 15 | @shareInfo.text "Shared (#{shareIdentifier})" 16 | 17 | hide: -> 18 | @shareInfo.text "" 19 | -------------------------------------------------------------------------------- /keymaps/firepad.cson: -------------------------------------------------------------------------------- 1 | # Keybindings require three things to be fully defined: A selector that is 2 | # matched against the focused element, the keystroke and the command to 3 | # execute. 4 | # 5 | # Below is a basic keybinding which registers on all platforms by applying to 6 | # the root workspace element. 7 | 8 | # For more detailed documentation see 9 | # https://atom.io/docs/latest/behind-atom-keymaps-in-depth 10 | 'atom-workspace': 11 | 'ctrl-alt-s': 'firepad:share' 12 | 'ctrl-alt-u': 'firepad:unshare' 13 | 'ctrl-alt-i': 'firepad:copyid' 14 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_line_length": { 3 | "level": "ignore" 4 | }, 5 | "no_empty_param_list": { 6 | "level": "error" 7 | }, 8 | "arrow_spacing": { 9 | "level": "error" 10 | }, 11 | "no_interpolation_in_single_quotes": { 12 | "level": "error" 13 | }, 14 | "no_debugger": { 15 | "level": "error" 16 | }, 17 | "prefer_english_operator": { 18 | "level": "error" 19 | }, 20 | "colon_assignment_spacing": { 21 | "spacing": { 22 | "left": 0, 23 | "right": 1 24 | }, 25 | "level": "error" 26 | }, 27 | "braces_spacing": { 28 | "spaces": 0, 29 | "level": "error" 30 | }, 31 | "spacing_after_comma": { 32 | "level": "error" 33 | }, 34 | "no_stand_alone_at": { 35 | "level": "error" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firepad", 3 | "main": "./lib/firepad", 4 | "version": "0.8.0", 5 | "description": "Collaborate on Code using Firepad - by Firebase", 6 | "activationCommands": { 7 | "atom-text-editor": [ 8 | "firepad:share", 9 | "firepad:unshare", 10 | "firepad:copyid" 11 | ] 12 | }, 13 | "repository": "https://github.com/Fankserver/atom-firepad", 14 | "license": "MIT", 15 | "engines": { 16 | "atom": ">=0.185.0 <2.0.0" 17 | }, 18 | "dependencies": { 19 | "event-kit": "^1.1.0", 20 | "firebase": "~1.0.5", 21 | "atom-space-pen-views": "^2.0.3", 22 | "space-pen": "^5.1.1" 23 | }, 24 | "consumedServices": { 25 | "status-bar": { 26 | "versions": { 27 | "^1.0.0": "consumeStatusBar" 28 | } 29 | } 30 | }, 31 | "keywords": [ 32 | "firepad", 33 | "collaboration", 34 | "share" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Anant Narayanan 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # atom-firepad 2 | 3 | [![Join the chat at https://gitter.im/Fankserver/atom-firepad](https://badges.gitter.im/Fankserver/atom-firepad.svg)](https://gitter.im/Fankserver/atom-firepad?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | This package for Atom adds collaborative editing support via [Firepad](http://firepad.io). 6 | Firepad is an OT library that implements collaborative text editing using [Firebase](https://www.firebase.com). 7 | 8 | To get started with this package, [first install it](https://atom.io/docs/v0.61.0/customizing-atom#installing-packages). 9 | Then, open a file to start collaborating via the `firepad:share` command 10 | (you can trigger this command via Cmd+Shift+P): 11 | 12 | ![Step 1: Share Command](http://i.imgur.com/B0JhyLC.png) 13 | 14 | Next, you'll be asked to enter a string identifying this session. The input is 15 | pre-filled with a random 8 character string that you can change. All users who 16 | enter the same string while sharing a document will see the same contents: 17 | 18 | ![Step 2: Enter Session Name](http://i.imgur.com/dIyCFXq.png) 19 | 20 | Finally, you can use the `firepad:unshare` command to stop collaborating. 21 | -------------------------------------------------------------------------------- /lib/firepad-tab-icon.coffee: -------------------------------------------------------------------------------- 1 | cssElements = {} 2 | 3 | module.exports = 4 | class TabIcon 5 | 6 | getCssElement: (path) -> 7 | cssElement = cssElements[path] 8 | unless cssElement? 9 | cssElement = document.createElement 'style' 10 | cssElement.setAttribute 'type', 'text/css' 11 | cssElements[path] = cssElement 12 | while cssElement.firstChild? 13 | cssElement.removeChild cssElement.firstChild 14 | path = path.replace(/\\/g,"\\\\") 15 | css = 16 | " 17 | ul.tab-bar > li.tab[data-path='#{path}'][is='tabs-tab'] > div.title::before { 18 | color: orange; 19 | content: '\\f037'; 20 | } 21 | " 22 | cssElement.appendChild document.createTextNode css 23 | return cssElement 24 | 25 | 26 | processPath: (path,revert=false) -> 27 | unless path? 28 | return 29 | cssElement = @getCssElement path 30 | unless revert 31 | tabDivs = atom.views.getView(atom.workspace).querySelectorAll "ul.tab-bar> 32 | li.tab[data-type='TextEditor']> 33 | div.title[data-path='#{path.replace(/\\/g,"\\\\")}']" 34 | for tabDiv in tabDivs 35 | tabDiv.parentElement.setAttribute "data-path", path 36 | unless cssElement.parentElement? 37 | head = document.getElementsByTagName('head')[0] 38 | head.appendChild cssElement 39 | else 40 | if cssElement.parentElement? 41 | cssElement.parentElement.removeChild(cssElement) 42 | -------------------------------------------------------------------------------- /lib/views/share-setup.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'atom' 2 | {TextEditorView} = require 'atom-space-pen-views' 3 | {View} = require 'space-pen' 4 | {Emitter} = require 'event-kit' 5 | 6 | module.exports = 7 | class ShareSetupView extends View 8 | @content: -> 9 | @div class: 'firepad overlay from-top mini', => 10 | @subview 'miniEditor', new TextEditorView(mini: true) 11 | @div class: 'message', outlet: 'message' 12 | 13 | detaching: false 14 | 15 | constructor: -> 16 | super 17 | @emitter = new Emitter 18 | 19 | initialize: -> 20 | @miniEditor.on 'focusout', => @detach() unless @detaching 21 | 22 | @subscriptions = new CompositeDisposable 23 | @subscriptions.add atom.commands.add 'atom-workspace', 'core:confirm': => 24 | if @miniEditor.getText() != '' 25 | @emitter.emit 'confirm', @miniEditor.getText() 26 | 27 | # add share icon on tab 28 | ColorTabs = require "../firepad-tab-icon" 29 | @colorTabs ?= new ColorTabs 30 | textEditor = atom.workspace.getActiveTextEditor() 31 | @colorTabs.processPath textEditor.getPath(), false 32 | 33 | atom.notifications.addInfo("Share the file.") 34 | 35 | @detach() 36 | 37 | @subscriptions.add atom.commands.add 'atom-workspace', 'core:cancel': => @detach() 38 | 39 | detach: -> 40 | return unless @hasParent() 41 | @detaching = true 42 | @miniEditor.setText('') 43 | #@emitter.dispose() 44 | super 45 | @detaching = false 46 | 47 | show: -> 48 | if atom.workspace.getActiveTextEditor() 49 | atom.views.getView(atom.workspace).appendChild(@element) 50 | 51 | @message.text('Enter a string to identify this share session') 52 | 53 | randomString = Math.random().toString(36).slice(2, 10) 54 | @miniEditor.setText(randomString) 55 | @miniEditor.focus() 56 | 57 | onDidConfirm: (callback) -> 58 | @emitter.on 'confirm', callback 59 | -------------------------------------------------------------------------------- /lib/firepad-share.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'atom' 2 | {Emitter} = require 'event-kit' 3 | Crypto = require 'crypto' 4 | os = require 'os' 5 | requireUncached = require './utils/requireUncached' 6 | 7 | 8 | module.exports = 9 | class FirepadShare 10 | 11 | constructor: (@editor, @shareIdentifier) -> 12 | @emitter = new Emitter 13 | @subscriptions = new CompositeDisposable 14 | 15 | @handleEditorEvents() 16 | 17 | hash = Crypto.createHash('sha256').update(@shareIdentifier).digest('base64') 18 | @userId = Math.random().toString(36).slice(2, 10) 19 | 20 | Firebase = requireUncached 'firebase' 21 | @firebase = new Firebase(atom.config.get('firepad.firebaseUrl')).child(hash) 22 | @firebaseUsers = @firebase.child('users') 23 | @firebaseContent = @firebase.child('content') 24 | 25 | @firebaseUsers.on 'value', (snapshot) => 26 | @attachDecoration(snapshot) 27 | 28 | @firebaseUserSelf = @firebaseUsers.push 29 | userId: @userId 30 | displayName: os.hostname() 31 | pos: @editor.getCursorBufferPosition() 32 | color: '#' + Math.floor(Math.random() * 256 * 256 * 256).toString(16) 33 | 34 | @firebaseCursor = @firebaseUserSelf.child('pos') 35 | 36 | @firebaseContent.on 'value', (snapshot) => 37 | content = snapshot.val() or @editor.getText() 38 | if not content 39 | @editor.setText '' 40 | else 41 | pos = @editor.getCursorBufferPosition() 42 | @editor.setText content 43 | @editor.setCursorScreenPosition pos 44 | # @shareview.show(@shareIdentifier) 45 | 46 | handleEditorEvents: -> 47 | @subscriptions.add @editor.onDidStopChanging => 48 | @updateContent() 49 | 50 | @subscriptions.add @editor.onDidChangeCursorPosition (event) => 51 | @updateCursorPosition(event) 52 | 53 | @subscriptions.add @editor.onDidDestroy => 54 | @remove() 55 | 56 | getEditor: -> 57 | @editor 58 | 59 | getShareIdentifier: -> 60 | @shareIdentifier 61 | 62 | updateContent: -> 63 | @firebaseContent.set(@editor.getText()) 64 | 65 | attachDecoration: (userSnapshot) -> 66 | # reset markers 67 | @destroyMarkers() 68 | 69 | # attach decorations 70 | users = userSnapshot.val() 71 | for uid, user of users 72 | # uncomment below if you want to hide the cursor of self 73 | # continue if user.userId is @userId 74 | marker = @editor.markBufferRange([ 75 | [user.pos.row, 0] 76 | [user.pos.row, 0] 77 | ], invalidate: 'never') 78 | @markers.push marker 79 | decoration = @editor.decorateMarker marker, 80 | type: 'overlay', 81 | item: @getCursorElement user 82 | 83 | destroyMarkers: -> 84 | @markers?.forEach (marker) => 85 | marker.destroy() 86 | @markers = [] 87 | 88 | getCursorElement: (user) -> 89 | element = document.createElement('div') 90 | element.setAttribute 'class', 'popover-list' 91 | element.setAttribute 'style', 92 | """ 93 | background-color: #{user.color}; 94 | display: block; 95 | padding: 3px; 96 | font-size: 12px; 97 | color: white; 98 | """ 99 | element.textContent = user.displayName 100 | element 101 | 102 | updateCursorPosition: (event) -> 103 | @firebaseCursor.set(event.newBufferPosition) 104 | 105 | remove: -> 106 | @firebaseUserSelf.remove() 107 | @firebaseUsers.off() 108 | @firebaseContent.off() 109 | @subscriptions.dispose() 110 | @destroyMarkers() 111 | @emitter.emit 'did-destroy' 112 | 113 | onDidDestroy: (callback) -> 114 | @emitter.on 'did-destroy', callback 115 | -------------------------------------------------------------------------------- /lib/firepad.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'atom' 2 | FirepadShare = require './firepad-share' 3 | 4 | 5 | module.exports = 6 | config: 7 | firebaseUrl: 8 | type: 'string' 9 | default: 'https://atom-firepad.firebaseio.com' 10 | 11 | shareStack: [] 12 | 13 | activate: (state) -> 14 | ShareSetupView = require './views/share-setup' 15 | @shareSetupView ?= new ShareSetupView 16 | 17 | @subscriptions = new CompositeDisposable 18 | 19 | @subscriptions.add atom.commands.add 'atom-text-editor', 'firepad:share': => @share() 20 | @subscriptions.add atom.commands.add 'atom-text-editor', 'firepad:unshare': => @unshare() 21 | @subscriptions.add atom.commands.add 'atom-text-editor', 'firepad:copyid': => @copyid() 22 | 23 | @subscriptions.add atom.workspace.observeActivePaneItem => @updateShareView() 24 | 25 | @subscriptions.add @shareSetupView.onDidConfirm (shareIdentifier) => @createShare(shareIdentifier) 26 | 27 | TabIcon = require "./firepad-tab-icon" 28 | @tabIcon ?= new TabIcon 29 | 30 | 31 | consumeStatusBar: (statusBar) -> 32 | ShareStatusBarView = require './views/share-status-bar' 33 | @shareStatusBarView ?= new ShareStatusBarView() 34 | @shareStatusBarTile = statusBar.addRightTile(item: @shareStatusBarView, priority: 100) 35 | 36 | deactivate: -> 37 | @subscriptions.dispose() 38 | 39 | @statusBarTile?.destroy() 40 | @statusBarTile = null 41 | 42 | createShare: (shareIdentifier) -> 43 | if shareIdentifier 44 | editor = atom.workspace.getActiveTextEditor() 45 | 46 | editorIsShared = false 47 | for share in @shareStack 48 | if share.getEditor() is editor 49 | editorIsShared = true 50 | 51 | if not editorIsShared 52 | share = new FirepadShare(editor, shareIdentifier) 53 | @subscriptions.add share.onDidDestroy => @destroyShare(share) 54 | 55 | @shareStack.push share 56 | @updateShareView() 57 | 58 | else 59 | atom.notifications.addError('Pane is shared') 60 | 61 | else 62 | atom.notifications.addError('No session key set') 63 | 64 | destroyShare: (share) -> 65 | shareStackIndex = @shareStack.indexOf share 66 | if shareStackIndex isnt -1 67 | @shareStack.splice shareStackIndex, 1 68 | @updateShareView() 69 | 70 | else 71 | console.error share, 'not found' 72 | 73 | updateShareView: -> 74 | if @shareStatusBarView 75 | editor = atom.workspace.getActiveTextEditor() 76 | 77 | editorIsShared = false 78 | for share in @shareStack 79 | if share.getEditor() is editor 80 | editorIsShared = true 81 | @shareStatusBarView.show(share.getShareIdentifier()) 82 | 83 | if not editorIsShared 84 | @shareStatusBarView.hide() 85 | 86 | share: -> 87 | @shareSetupView.show() 88 | 89 | unshare: -> 90 | editor = atom.workspace.getActiveTextEditor() 91 | 92 | #remove the icon 93 | @tabIcon.processPath editor.getPath(), true 94 | 95 | editorIsShared = false 96 | for share in @shareStack 97 | if share.getEditor() is editor 98 | editorIsShared = true 99 | share.remove() 100 | atom.notifications.addInfo("Unhare the file.") 101 | break 102 | 103 | if not editorIsShared 104 | atom.notifications.addError('Pane is not shared') 105 | 106 | copyid: -> 107 | editor = atom.workspace.getActiveTextEditor() 108 | 109 | for share in @shareStack 110 | if share.getEditor() is editor 111 | atom.clipboard.write(share.getShareIdentifier()) 112 | atom.notifications.addInfo("Copy the shareIdentifier \"" + share.getShareIdentifier() + "\" to clipboard.") 113 | --------------------------------------------------------------------------------