├── .eslintignore ├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── keymaps └── jupyter-notebook-atom.cson ├── lib ├── dispatcher.js ├── display-area.js ├── main.js ├── notebook-cell.js ├── notebook-editor-view.js ├── notebook-editor.js └── text-editor.js ├── menus └── jupyter-notebook-atom.cson ├── package.json ├── spec ├── jupyter-notebook-atom-spec.coffee └── jupyter-notebook-atom-view-spec.coffee └── styles └── jupyter-notebook-atom.less /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | **/node_modules/* 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaFeatures": { 3 | "jsx": true, 4 | "modules": true 5 | }, 6 | "env": { 7 | "browser": true, 8 | "es6": true, 9 | "node": true 10 | }, 11 | "parser": "babel-eslint", 12 | "rules": { 13 | "camelcase": [0, {"properties": "never"}], 14 | "curly": [0, "multi"], 15 | "eol-last": 0, 16 | "max-len": [0, 120, 4], 17 | "no-underscore-dangle": [0], 18 | "no-var": 2, 19 | "no-use-before-define": [2, "nofunc"], 20 | "react/display-name": 0, 21 | "react/jsx-boolean-value": 2, 22 | "react/jsx-quotes": 2, 23 | "react/jsx-no-undef": 2, 24 | "react/jsx-sort-props": 0, 25 | "react/jsx-uses-react": 2, 26 | "react/jsx-uses-vars": 2, 27 | "react/no-did-mount-set-state": 0, 28 | "react/no-did-update-set-state": 2, 29 | "react/no-multi-comp": 0, 30 | "react/no-unknown-property": 2, 31 | "react/prop-types": 0, 32 | "react/react-in-jsx-scope": 2, 33 | "react/self-closing-comp": 2, 34 | "react/wrap-multilines": 2, 35 | "quotes": [2, "single"], 36 | "space-before-function-paren": [2, { 37 | "anonymous": "always", 38 | "named": "never" 39 | }], 40 | "strict": [2, "global"] 41 | }, 42 | "plugins": [ 43 | "react" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules 4 | 5 | *.ipynb 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.0 - First Release 2 | * Every feature added 3 | * Every bug fixed 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Will Whitney 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 | # jupyter-notebook 2 | 3 | > This project does not have any active maintainers. We recommend that you use [nteract](https://nteract.io/desktop), a native notebook application built using Electron, React (like this project), Redux, and RxJS, or [Hydrogen](https://nteract.io/atom), a LightTable-inspired package for Atom that allows users to run code blocks and selections using Jupyter kernels. 4 | 5 | A package that works like the [Jupyter Notebook](http://jupyter.org/), but inside Atom. It's registered as an opener for `.ipynb` files — try opening one! 6 | 7 | ![Sweet baby integration](http://i.imgur.com/100MtXR.png) 8 | 9 | ## Install 10 | 11 | 1. Install dependencies: 12 | 13 | ##### OS X 14 | 15 | * Python 3: `brew install python3` (there are [issues](http://apple.stackexchange.com/questions/209572/how-to-use-pip-after-the-el-capitan-max-os-x-upgrade) with pip2 and OS X 10.11) 16 | * Jupyter and Jupyter Kernel Gateway: `pip3 install jupyter jupyter_kernel_gateway` 17 | 18 | ##### Linux (Debian) 19 | 20 | * Python: `sudo apt-get install python python-pip` 21 | * Jupyter and Jupyter Kernel Gateway: `pip install jupyter jupyter_kernel_gateway` 22 | 23 | 2. `apm install jupyter-notebook` or search for *jupyter-notebook* inside of Atom 24 | 25 | ## Usage 26 | 27 | * Run cell: SHIFT+ENTER, CMD+ENTER (or CTRL+ENTER on Windows) 28 | 29 | ## Developers 30 | 31 | ### Install 32 | 33 | 1. `git clone https://github.com/jupyter/atom-notebook.git` 34 | 2. `apm install` 35 | 3. `apm link` 36 | 37 | ### Achitecture 38 | 39 | This package is built on React and the Flux architecture. 40 | 41 | #### Map 42 | 43 | - **main** tells Atom how to render `NotebookEditor` and registers as an Opener for `.ipynb` files 44 | - **dispatcher** is a singleton flux.Dispatcher which contains the list of valid actions 45 | - **notebook-editor** is the Store and handles all of the business logic. It loads the file in, creates a state, then receives Actions and updates the state accordingly. 46 | - **notebook-editor-view**, notebook-cell, text-editor, display-area are the views. notebook-editor-view updates its state by fetching it from notebook-editor, then passes appropriate bits of that state down to the other views as props. 47 | 48 | #### Flow 49 | 50 | **Rendering:** `NotebookEditor -> NotebookEditorView -> [child views]` 51 | 52 | **Updating:** `[external action] -> Dispatcher.dispatch -> NotebookEditor.onAction ?-> NotebookEditor._onChange -> NotebookEditorView._onChange` 53 | 54 | #### Immutable state 55 | 56 | The state returned by `NotebookEditor.getState` is an [`Immutable.js`](https://facebook.github.io/immutable-js/) object. 57 | 58 | Accessing its properties inside a view looks like this: 59 | 60 | ```javascript 61 | let executionCount = this.props.data.get('execution_count'); 62 | ``` 63 | 64 | Changing it (in NotebookEditor) looks like this: 65 | 66 | ```javascript 67 | this.state = this.state.setIn( 68 | ['cells', cellIndex, 'source'], 69 | payload.source); 70 | ``` 71 | 72 | or this: 73 | 74 | ```javascript 75 | outputs = outputs.push(el.outerHTML); 76 | ``` 77 | 78 | Since React requires a view's state to be a regular JS object, the state of NotebookEditorView takes the form: 79 | 80 | ```javascript 81 | { 82 | data: 83 | } 84 | ``` 85 | 86 | No other views have state. 87 | 88 | ### To do 89 | 90 | - autocomplete 91 | - `atom.workspace.getActiveTextEditor()` returns `undefined` because `atom.workspace.getActivePaneItem()` returns our custom NotebookEditor class which contains one or more TextEditors, therefore autocomplete, find, and features provided by other packages don't work in cells 92 | - add more actions (duplicate cell, restart kernel, change cell type, etc) 93 | - tell React [our rendering is pure](https://facebook.github.io/react/docs/advanced-performance.html) 94 | - test rendering performance with big notebooks 95 | -------------------------------------------------------------------------------- /keymaps/jupyter-notebook-atom.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 | 11 | 'atom-workspace': 12 | 'ctrl-alt-o': 'jupyter-notebook-atom:toggle' 13 | 'atom-workspace .notebook-cell atom-text-editor:not([mini]), atom-workspace .notebook-cell atom-text-editor': 14 | 'shift-enter': 'jupyter-notebook-atom:run' 15 | 'cmd-enter': 'jupyter-notebook-atom:run' 16 | -------------------------------------------------------------------------------- /lib/dispatcher.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import flux from 'flux'; 4 | 5 | let Dispatcher = new flux.Dispatcher(); 6 | Dispatcher.actions = { 7 | add_cell: Symbol('add_cell'), 8 | run_cell: Symbol('run_cell'), 9 | run_active_cell: Symbol('run_active_cell'), 10 | output_received: Symbol('output_received'), 11 | cell_source_changed: Symbol('cell_source_changed'), 12 | cell_focus: Symbol('cell_focus'), 13 | interrupt_kernel: Symbol('interrupt_kernel'), 14 | destroy: Symbol('destroy') 15 | } 16 | 17 | export default Dispatcher; 18 | -------------------------------------------------------------------------------- /lib/display-area.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import React from 'react'; 4 | import {Transformime} from 'transformime'; 5 | import { 6 | StreamTransformer, 7 | TracebackTransformer, 8 | MarkdownTransformer, 9 | LaTeXTransformer, 10 | PDFTransformer 11 | } from 'transformime-jupyter-transformers'; 12 | 13 | export default class DisplayArea extends React.Component { 14 | 15 | constructor(props) { 16 | super(props); 17 | this.transformer = new Transformime(); 18 | this.transformer.transformers.push(new StreamTransformer()); 19 | this.transformer.transformers.push(new TracebackTransformer()); 20 | this.transformer.transformers.push(new MarkdownTransformer()); 21 | this.transformer.transformers.push(new LaTeXTransformer()); 22 | this.transformer.transformers.push(new PDFTransformer()); 23 | this.state = { 24 | outputs: [] 25 | }; 26 | } 27 | 28 | componentWillMount() { 29 | this.transformMimeBundle(this.props); 30 | } 31 | 32 | componentWillReceiveProps(nextProps) { 33 | this.transformMimeBundle(nextProps); 34 | } 35 | 36 | render() { 37 | return ( 38 |
42 |
43 | ); 44 | } 45 | 46 | transformMimeBundle(props) { 47 | if (props.data.get('outputs')) { 48 | let promises = props.data.get('outputs').toJS().map(output => { 49 | let mimeBundle = this.makeMimeBundle(output); 50 | if (mimeBundle) { 51 | return this.transformer.transformRichest(mimeBundle, document).then(mime => mime.el.outerHTML); 52 | } else return; 53 | }); 54 | Promise.all(promises).then(outputs => { 55 | this.setState({outputs}); 56 | }); 57 | } 58 | } 59 | 60 | makeMimeBundle(msg) { 61 | let bundle = {}; 62 | switch (msg.output_type) { 63 | case 'execute_result': 64 | case 'display_data': 65 | bundle = msg.data; 66 | break; 67 | case 'stream': 68 | bundle = {'text/plain': msg.text.join('')}; 69 | // bundle = {'jupyter/stream': msg}; 70 | break; 71 | case 'error': 72 | bundle = { 73 | 'jupyter/traceback': msg 74 | }; 75 | break; 76 | default: 77 | console.warn('Unrecognized output type: ' + msg.output_type); 78 | bundle = { 79 | 'text/plain': 'Unrecognized output type' + JSON.stringify(msg) 80 | }; 81 | } 82 | return bundle; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import {CompositeDisposable} from 'atom'; 6 | import path, {delimiter} from 'path'; 7 | import Dispatcher from './dispatcher'; 8 | import NotebookEditor from './notebook-editor'; 9 | import NotebookEditorView from './notebook-editor-view'; 10 | 11 | export default { 12 | 13 | config: { 14 | jupyterPath: { 15 | title: 'Path to jupyter binary', 16 | description: '', 17 | type: 'string', 18 | default: 'usr/local/bin' 19 | } 20 | }, 21 | 22 | activate(state) { 23 | // console.log('Activated'); 24 | fixPath(); 25 | this.openerDisposable = atom.workspace.addOpener(openURI); 26 | this.commands = atom.commands.add('.notebook-cell atom-text-editor', 'jupyter-notebook-atom:run', this.run); 27 | atom.views.addViewProvider(NotebookEditor, 28 | model => { 29 | let el = document.createElement('div'); 30 | el.classList.add('notebook-wrapper'); 31 | let viewComponent = ReactDOM.render( 32 | , 33 | el); 34 | return el; 35 | }); 36 | }, 37 | 38 | 39 | deactivate() { 40 | Dispatcher.dispatch({ 41 | actionType: Dispatcher.actions.destroy 42 | }); 43 | this.openerDisposable.dispose(); 44 | this.commands.dispose(); 45 | }, 46 | 47 | toggle() { 48 | console.log('JupyterNotebookAtom was toggled!'); 49 | if (this.modalPanel.isVisible()) { 50 | return this.modalPanel.hide(); 51 | } else { 52 | return this.modalPanel.show(); 53 | } 54 | }, 55 | 56 | run() { 57 | // console.log('Run cell'); 58 | Dispatcher.dispatch({ 59 | actionType: Dispatcher.actions.run_active_cell 60 | // cellID: this.props.data.getIn(['metadata', 'id']) 61 | }); 62 | } 63 | 64 | }; 65 | 66 | function fixPath() { 67 | let defaultPaths = [ 68 | '/usr/local/bin', 69 | '/usr/bin', 70 | '/bin', 71 | '/usr/local/sbin', 72 | '/usr/sbin', 73 | '/sbin', 74 | './node_modules/.bin' 75 | ]; 76 | let jupyterPath = atom.config.get('jupyter-notebook.jupyterPath'); 77 | if (defaultPaths.indexOf(jupyterPath) < 0) defaultPaths.unshift(jupyterPath); 78 | if (process.platform === 'darwin') { 79 | process.env.PATH = process.env.PATH.split(delimiter).reduce((result, path) => { 80 | if (!result.find(item => item === path)) result.push(path); 81 | return result; 82 | }, defaultPaths).join(delimiter); 83 | } 84 | } 85 | 86 | function openURI(uriToOpen) { 87 | const notebookExtensions = ['.ipynb']; 88 | let uriExtension = path.extname(uriToOpen).toLowerCase(); 89 | if (notebookExtensions.find(extension => extension === uriExtension)) return new NotebookEditor(uriToOpen); 90 | } 91 | -------------------------------------------------------------------------------- /lib/notebook-cell.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import path from 'path'; 4 | import fs from 'fs-plus'; 5 | import File from 'pathwatcher'; 6 | import React from 'react'; 7 | import Immutable from 'immutable'; 8 | import {CompositeDisposable} from 'atom'; 9 | import Dispatcher from './dispatcher'; 10 | import DisplayArea from './display-area'; 11 | import Editor from './text-editor'; 12 | 13 | export default class NotebookCell extends React.Component { 14 | 15 | constructor(props) { 16 | super(props); 17 | } 18 | 19 | render() { 20 | // console.log('Cell rendering.'); 21 | let focusClass = ''; 22 | if (this.props.data.getIn(['metadata', 'focus'])) focusClass = ' focused'; 23 | return ( 24 |
25 |
26 | In [{this.props.data.get('execution_count') || ' '}]: 27 |
28 |
29 | 30 | 31 |
32 |
33 | ); 34 | // 39 | } 40 | 41 | triggerFocused(isFocused) { 42 | Dispatcher.dispatch({ 43 | actionType: Dispatcher.actions.cell_focus, 44 | cellID: this.props.data.getIn(['metadata', 'id']), 45 | isFocused: isFocused 46 | }); 47 | } 48 | 49 | runCell = () => { 50 | Dispatcher.dispatch({ 51 | actionType: Dispatcher.actions.run_cell, 52 | cellID: this.props.data.getIn(['metadata', 'id']) 53 | }); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /lib/notebook-editor-view.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import path from 'path'; 4 | import fs from 'fs-plus'; 5 | import File from 'pathwatcher'; 6 | import React from 'react'; 7 | import Immutable from 'immutable'; 8 | import {CompositeDisposable} from 'atom'; 9 | import {$, ScrollView} from 'atom-space-pen-views'; 10 | import NotebookCell from './notebook-cell'; 11 | 12 | export default class NotebookEditorView extends React.Component { 13 | 14 | constructor(props) { 15 | super(props); 16 | this.store = props.store; 17 | this.subscriptions = new CompositeDisposable(); 18 | //TODO: remove these development handles 19 | global.editorView = this; 20 | } 21 | 22 | componentDidMount() { 23 | this.subscriptions.add(this.store.addStateChangeListener(this._onChange)); 24 | } 25 | 26 | componentDidUpdate(prevProps, prevState) { 27 | 28 | } 29 | 30 | componentWillUnmount() { 31 | this.subscriptions.dispose(); 32 | } 33 | 34 | render() { 35 | // console.log('notebookeditorview render called'); 36 | let language = this.state.data.getIn(['metadata', 'language_info', 'name']); 37 | // console.log('Language:', language); 38 | let notebookCells = this.state.data.get('cells').map((cell) => { 39 | cell = cell.set('language', language); 40 | return ( 41 | 46 | ); 47 | }); 48 | return ( 49 |
50 |
51 | 52 |
53 | 54 | 55 |
56 |
57 |
58 |
59 | {notebookCells} 60 |
61 |
62 |
63 | ); 64 | } 65 | 66 | addCell() { 67 | Dispatcher.dispatch({ 68 | actionType: Dispatcher.actions.add_cell 69 | // cellID: this.props.data.getIn(['metadata', 'id']) 70 | }); 71 | } 72 | 73 | runActiveCell() { 74 | Dispatcher.dispatch({ 75 | actionType: Dispatcher.actions.run_active_cell 76 | // cellID: this.props.data.getIn(['metadata', 'id']) 77 | }); 78 | } 79 | 80 | interruptKernel() { 81 | Dispatcher.dispatch({ 82 | actionType: Dispatcher.actions.interrupt_kernel 83 | // cellID: this.props.data.getIn(['metadata', 'id']) 84 | }); 85 | } 86 | 87 | _fetchState = () => { 88 | // console.log('fetching NE state'); 89 | if (this.store !== undefined) { 90 | return this.store.getState(); 91 | } else { 92 | return Immutable.Map(); 93 | } 94 | }; 95 | 96 | // private onChange handler for use in callbacks 97 | _onChange = () => { 98 | let newState = this._fetchState(); 99 | // console.log('Setting state:', newState.toString()); 100 | this.setState({data: newState}); 101 | }; 102 | 103 | // set the initial state 104 | state = { 105 | data: this.props.store.getState() 106 | }; 107 | 108 | } 109 | -------------------------------------------------------------------------------- /lib/notebook-editor.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import path from 'path'; 4 | import portfinder from 'portfinder'; 5 | import fs from 'fs-plus'; 6 | import React from 'react'; 7 | import uuid from 'uuid'; 8 | import Immutable from 'immutable'; 9 | import {CompositeDisposable} from 'atom'; 10 | import {File} from 'pathwatcher'; 11 | import {Emitter} from 'event-kit'; 12 | import { 13 | listRunningKernels, 14 | connectToKernel, 15 | startNewKernel, 16 | getKernelSpecs 17 | } from 'jupyter-js-services'; 18 | import {XMLHttpRequest} from 'xmlhttprequest'; 19 | import ws from 'ws'; 20 | import { 21 | spawn, 22 | execSync 23 | } from 'child_process'; 24 | import Dispatcher from './dispatcher'; 25 | import NotebookEditorView from './notebook-editor-view'; 26 | import NotebookCell from './notebook-cell'; 27 | global.XMLHttpRequest = XMLHttpRequest; 28 | global.WebSocket = ws; 29 | 30 | export default class NotebookEditor { 31 | 32 | constructor(uri) { 33 | console.log('NotebookEditor created for', uri); 34 | this.loadNotebookFile(uri); 35 | this.emitter = new Emitter(); 36 | this.subscriptions = new CompositeDisposable(); 37 | this.launchKernelGateway(); 38 | Dispatcher.register(this.onAction); 39 | //TODO: remove these development handles 40 | global.editor = this; 41 | global.Dispatcher = Dispatcher; 42 | } 43 | 44 | findCellByID(id) { 45 | return this.state.get('cells').findEntry(cell => cell.getIn(['metadata', 'id']) == id); 46 | } 47 | 48 | findActiveCell() { 49 | return this.state.get('cells').findEntry(cell => cell.getIn(['metadata', 'focus'])); 50 | } 51 | 52 | onAction = (payload) => { 53 | console.log(`Action '${payload.actionType.toString()}'received in NotebookEditor`); 54 | // TODO: add a notebook ID field to events and filter on it 55 | let cellInfo, 56 | cellIndex, 57 | cell; 58 | switch (payload.actionType) { 59 | case Dispatcher.actions.add_cell: 60 | let newCell = Immutable.fromJS({ 61 | cell_type: 'code', 62 | execution_count: null, 63 | metadata: { 64 | collapsed: false 65 | }, 66 | outputs: [], 67 | source: [] 68 | }); 69 | this.state = this.state.set('cells', this.state.get('cells').push(newCell)); 70 | this.setModified(true); 71 | this._onChange(); 72 | break; 73 | case Dispatcher.actions.cell_source_changed: 74 | cellInfo = this.findCellByID(payload.cellID); 75 | if (!cellInfo || cellInfo === undefined || cellInfo === null) { 76 | // return console.log('Message is for another notebook'); 77 | return; 78 | } else { 79 | [cellIndex, cell] = cellInfo; 80 | } 81 | this.state = this.state.setIn(['cells', cellIndex, 'source'], payload.source); 82 | this.setModified(true); 83 | break; 84 | case Dispatcher.actions.cell_focus: 85 | let activeCellInfo = this.findActiveCell(); 86 | if (!activeCellInfo || activeCellInfo === undefined || activeCellInfo === null) { 87 | // return console.log('Message is for another notebook'); 88 | return; 89 | } else { 90 | [activeCellIndex, activeCell] = activeCellInfo; 91 | // console.log(`Cell is at index ${cellIndex}`); 92 | } 93 | this.state = this.state.setIn(['cells', activeCellIndex, 'metadata', 'focus'], false); 94 | cellInfo = this.findCellByID(payload.cellID); 95 | if (!cellInfo || cellInfo === undefined || cellInfo === null) { 96 | // return console.log('Message is for another notebook'); 97 | return; 98 | } else { 99 | [cellIndex, cell] = cellInfo; 100 | } 101 | this.state = this.state.setIn(['cells', cellIndex, 'metadata', 'focus'], payload.isFocused); 102 | this._onChange(); 103 | break; 104 | case Dispatcher.actions.run_cell: 105 | this.runCell(this.findCellByID(payload.cellID)); 106 | break; 107 | case Dispatcher.actions.run_active_cell: 108 | this.runCell(this.findActiveCell()); 109 | break; 110 | case Dispatcher.actions.output_received: 111 | cellInfo = this.findCellByID(payload.cellID); 112 | if (!cellInfo || cellInfo === undefined || cellInfo === null) { 113 | // return console.log('Message is for another notebook'); 114 | return; 115 | } else { 116 | [cellIndex, cell] = cellInfo; 117 | } 118 | console.log('output_received', payload.message.content); 119 | let outputBundle = this.makeOutputBundle(payload.message); 120 | if (outputBundle) { 121 | let outputs = this.state.getIn(['cells', cellIndex, 'outputs']).toJS(); 122 | let index = outputs.findIndex(output => output.output_type === outputBundle.output_type); 123 | if (index > -1) { 124 | if (outputBundle.data) { 125 | outputs[index].data = outputs[index].data.concat(outputBundle.data); 126 | } 127 | if (outputBundle.text) { 128 | if (outputs[index].name === outputBundle.name) { 129 | outputs[index].text = outputs[index].text.concat(outputBundle.text); 130 | } else { 131 | outputs = outputs.concat(outputBundle); 132 | } 133 | } 134 | } else { 135 | outputs = outputs.concat(outputBundle); 136 | } 137 | let execution_count = this.state.getIn(['cells', cellIndex, 'execution_count']); 138 | if (outputBundle.execution_count) execution_count = outputBundle.execution_count; 139 | let newCell = this.state.getIn(['cells', cellIndex]).merge({ 140 | execution_count, 141 | outputs 142 | }); 143 | this.state = this.state.setIn(['cells', cellIndex], newCell); 144 | this.setModified(true); 145 | this._onChange(); 146 | } 147 | break; 148 | case Dispatcher.actions.interrupt_kernel: 149 | if (this.session === undefined || this.session === null) { 150 | return atom.notifications.addError('atom-notebook', { 151 | detail: 'No running Jupyter session. Try closing and re-opening this file.', 152 | dismissable: true 153 | }); 154 | } 155 | this.session.interrupt().then(() => console.log('this.session.interrupt')); 156 | break; 157 | case Dispatcher.actions.destroy: 158 | if (this.session === undefined || this.session === null) { 159 | return atom.notifications.addError('atom-notebook', { 160 | detail: 'No running Jupyter session. Try closing and re-opening this file.', 161 | dismissable: true 162 | }); 163 | } 164 | destroy(); 165 | break; 166 | } 167 | } 168 | 169 | runCell(cellInfo) { 170 | let future, timer; 171 | if (!cellInfo || cellInfo === undefined || cellInfo === null) { 172 | // return console.log('Message is for another notebook'); 173 | return; 174 | } else { 175 | [cellIndex, cell] = cellInfo; 176 | // console.log(`Cell is at index ${cellIndex}`); 177 | } 178 | if (this.session === undefined || this.session === null) { 179 | return atom.notifications.addError('atom-notebook', { 180 | detail: 'No running Jupyter session. Try closing and re-opening this file.', 181 | dismissable: true 182 | }); 183 | } 184 | if (cell.get('cell_type') !== 'code') return; 185 | this.state = this.state.setIn(['cells', cellIndex, 'outputs'], Immutable.List()); 186 | future = this.session.execute({code: cell.get('source')}, false); 187 | future.onDone = () => { 188 | console.log('output_received', 'done'); 189 | timer = setTimeout(() => future.dispose(), 3000); 190 | } 191 | future.onIOPub = (msg) => { 192 | Dispatcher.dispatch({ 193 | actionType: Dispatcher.actions.output_received, 194 | cellID: cell.getIn(['metadata', 'id']), 195 | message: msg 196 | }); 197 | clearTimeout(timer); 198 | } 199 | this._onChange(); 200 | } 201 | 202 | addStateChangeListener(callback) { 203 | return this.emitter.on('state-changed', callback); 204 | } 205 | 206 | _onChange = () => { 207 | this.emitter.emit('state-changed'); 208 | } 209 | 210 | getState() { 211 | return this.state; 212 | } 213 | 214 | loadNotebookFile(uri) { 215 | // console.log('LOAD NOTEBOOK FILE'); 216 | this.file = new File(uri); 217 | let parsedFile = this.parseNotebookFile(this.file); 218 | if (parsedFile.cells) { 219 | parsedFile.cells = parsedFile.cells.map(cell => { 220 | cell.metadata.id = uuid.v4(); 221 | cell.metadata.focus = false; 222 | return cell; 223 | }); 224 | } else { 225 | parsedFile.cells = [ 226 | { 227 | cell_type: 'code', 228 | execution_count: null, 229 | metadata: { 230 | collapsed: true 231 | }, 232 | outputs: [], 233 | source: [] 234 | } 235 | ]; 236 | } 237 | if (parsedFile.cells.length > 0) parsedFile.cells[0].metadata.focus = true; 238 | this.state = Immutable.fromJS(parsedFile); 239 | } 240 | 241 | parseNotebookFile(file) { 242 | let fileString = this.file.readSync(); 243 | return JSON.parse(fileString); 244 | } 245 | 246 | launchKernelGateway() { 247 | let language = this.state.getIn(['metadata', 'kernelspec', 'language']); 248 | portfinder.basePort = 8888; 249 | portfinder.getPort({host: 'localhost'}, (err, port) => { 250 | if (err) throw err; 251 | this.kernelGateway = spawn('jupyter', ['kernelgateway', '--KernelGatewayApp.ip=localhost', `--KernelGatewayApp.port=${port}`], { 252 | cwd: atom.project.getPaths()[0] 253 | }); 254 | this.kernelGateway.stdout.on('data', (data) => { 255 | console.log('kernelGateway.stdout ' + data); 256 | }); 257 | this.kernelGateway.stderr.on('data', (data) => { 258 | console.log('kernelGateway.stderr ' + data); 259 | if (data.toString().includes('The Jupyter Kernel Gateway is running at')) { 260 | getKernelSpecs({baseUrl: `http://localhost:${port}`}).then((kernelSpecs) => { 261 | let spec = Object.keys(kernelSpecs.kernelspecs).find(kernel => kernelSpecs.kernelspecs[kernel].spec.language === language); 262 | console.log('Kernel: ', spec); 263 | if (spec) { 264 | startNewKernel({ 265 | baseUrl: `http://localhost:${port}`, 266 | wsUrl: `ws://localhost:${port}`, 267 | name: spec 268 | }).then((kernel) => { 269 | this.session = kernel; 270 | }); 271 | } 272 | }); 273 | } 274 | }); 275 | this.kernelGateway.on('close', (code) => { 276 | console.log('kernelGateway.close ' + code); 277 | }); 278 | this.kernelGateway.on('exit', (code) => { 279 | console.log('kernelGateway.exit ' + code); 280 | }); 281 | }); 282 | } 283 | 284 | makeOutputBundle(msg) { 285 | let json = {}; 286 | json.output_type = msg.header.msg_type; 287 | switch (json.output_type) { 288 | case 'clear_output': 289 | // msg spec v4 had stdout, stderr, display keys 290 | // v4.1 replaced these with just wait 291 | // The default behavior is the same (stdout=stderr=display=True, wait=False), 292 | // so v4 messages will still be properly handled, 293 | // except for the rarely used clearing less than all output. 294 | console.log('Not handling clear message!'); 295 | this.clear_output(msg.content.wait || false); 296 | return; 297 | case 'stream': 298 | json.text = msg.content.text.match(/[^\n]+(?:\r?\n|$)/g); 299 | json.name = msg.content.name; 300 | break; 301 | case 'display_data': 302 | json.data = Object.keys(msg.content.data).reduce((result, key) => { 303 | result[key] = msg.content.data[key].match(/[^\n]+(?:\r?\n|$)/g); 304 | return result; 305 | }, {}); 306 | json.metadata = msg.content.metadata; 307 | break; 308 | case 'execute_result': 309 | json.data = Object.keys(msg.content.data).reduce((result, key) => { 310 | result[key] = msg.content.data[key].match(/[^\n]+(?:\r?\n|$)/g); 311 | return result; 312 | }, {}); 313 | json.metadata = msg.content.metadata; 314 | json.execution_count = msg.content.execution_count; 315 | break; 316 | case 'error': 317 | json.ename = msg.content.ename; 318 | json.evalue = msg.content.evalue; 319 | json.traceback = msg.content.traceback; 320 | break; 321 | case 'status': 322 | case 'execute_input': 323 | return false; 324 | default: 325 | console.log('unhandled output message', msg); 326 | return false; 327 | } 328 | return json; 329 | } 330 | 331 | save() { 332 | this.saveAs(this.getPath()); 333 | } 334 | 335 | saveAs(uri) { 336 | let nbData = this.asJSON() 337 | try { 338 | fs.writeFileSync(uri, nbData); 339 | this.modified = false; 340 | } catch(e) { 341 | console.error(e.stack); 342 | debugger; 343 | } 344 | this.emitter.emit('did-change-modified'); 345 | } 346 | 347 | asJSON() { 348 | return JSON.stringify(this.state.toJSON(), null, 4); 349 | } 350 | 351 | shouldPromptToSave() { 352 | return this.isModified(); 353 | } 354 | 355 | getSaveDialogOptions() { 356 | return {}; 357 | } 358 | 359 | modified = false; 360 | // modifiedCallbacks = []; 361 | 362 | isModified() { 363 | return this.modified; 364 | } 365 | 366 | setModified(modified) { 367 | // console.log('setting modified'); 368 | this.modified = modified; 369 | this.emitter.emit('did-change-modified'); 370 | } 371 | 372 | onDidChangeModified(callback) { 373 | return this.emitter.on('did-change-modified', callback); 374 | } 375 | 376 | //---------------------------------------- 377 | // Listeners, currently never called 378 | //---------------------------------------- 379 | 380 | onDidChange(callback) { 381 | return this.emitter.on('did-change', callback); 382 | } 383 | 384 | onDidChangeTitle(callback) { 385 | return this.emitter.on('did-change-title', callback); 386 | } 387 | 388 | //---------------------------------------- 389 | // Various info-fetching methods 390 | //---------------------------------------- 391 | 392 | getTitle() { 393 | let filePath = this.getPath(); 394 | if (filePath !== undefined && filePath !== null) { 395 | return path.basename(filePath); 396 | } else { 397 | return 'untitled'; 398 | } 399 | } 400 | 401 | getURI() { 402 | // console.log('getURI called'); 403 | return this.getPath(); 404 | } 405 | 406 | getPath() { 407 | // console.log('getPath called'); 408 | return this.file.getPath(); 409 | } 410 | 411 | isEqual(other) { 412 | return (other instanceof ImageEditor && this.getURI() == other.getURI()); 413 | } 414 | 415 | destroy() { 416 | console.log('destroy called'); 417 | if (this.subscriptions) this.subscriptions.dispose(); 418 | if (this.session) { 419 | this.session.shutdown().then(() => { 420 | this.kernelGateway.stdin.pause(); 421 | this.kernelGateway.kill(); 422 | }); 423 | } 424 | } 425 | 426 | //---------------------------------------- 427 | // Serialization (one of these days...) 428 | //---------------------------------------- 429 | 430 | // static deserialize({filePath}) { 431 | // if (fs.isFileSync(filePath)) { 432 | // new NotebookEditor(filePath); 433 | // } else { 434 | // console.warn(`Could not deserialize notebook editor for path \ 435 | // '${filePath}' because that file no longer exists.`); 436 | // } 437 | // } 438 | 439 | // serialize() { 440 | // return { 441 | // filePath: this.getPath(), 442 | // deserializer: this.constructor.name 443 | // } 444 | // } 445 | 446 | } 447 | 448 | // atom.deserializers.add(NotebookEditor); 449 | -------------------------------------------------------------------------------- /lib/text-editor.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import {CompositeDisposable} from 'atom'; 6 | import Dispatcher from './dispatcher'; 7 | 8 | export default class Editor extends React.Component { 9 | 10 | constructor(props) { 11 | super(props); 12 | this.subscriptions = new CompositeDisposable(); 13 | } 14 | 15 | componentDidMount() { 16 | this.textEditorView = ReactDOM.findDOMNode(this); 17 | this.textEditor = this.textEditorView.getModel(); 18 | let grammar = Editor.getGrammarForLanguage(this.props.language); 19 | this.textEditor.setGrammar(grammar); 20 | this.textEditor.setLineNumberGutterVisible(true); 21 | // Prevent `this.onTextChanged` on initial `onDidStopChanging` 22 | setTimeout(() => { 23 | this.subscriptions.add(this.textEditor.onDidStopChanging(this.onTextChanged)) 24 | }, 1000); 25 | } 26 | 27 | shouldComponentUpdate() { 28 | return false; 29 | } 30 | 31 | componentWillUnmount() { 32 | this.subscriptions.dispose(); 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 | {this.props.data.get('source')} 39 | 40 | ); 41 | } 42 | 43 | static getGrammarForLanguage(language) { 44 | let matchingGrammars = atom.grammars.grammars.filter(grammar => { 45 | return grammar !== atom.grammars.nullGrammar && (grammar.name != null) && (grammar.name.toLowerCase != null) && grammar.name.toLowerCase() === language; 46 | }); 47 | return matchingGrammars[0]; 48 | } 49 | 50 | onTextChanged = () => { 51 | Dispatcher.dispatch({ 52 | actionType: Dispatcher.actions.cell_source_changed, 53 | cellID: this.props.data.getIn(['metadata', 'id']), 54 | source: this.textEditor.getText() 55 | }); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /menus/jupyter-notebook-atom.cson: -------------------------------------------------------------------------------- 1 | # See https://atom.io/docs/latest/hacking-atom-package-word-count#menus for more details 2 | 'context-menu': 3 | '.notebook-cell atom-text-editor': [ 4 | { 5 | 'label': 'Run cell' 6 | 'command': 'jupyter-notebook-atom:run' 7 | } 8 | ] 9 | # 'menu': [ 10 | # { 11 | # 'label': 'Packages' 12 | # 'submenu': [ 13 | # 'label': 'jupyter-notebook-atom' 14 | # 'submenu': [ 15 | # { 16 | # 'label': 'Toggle' 17 | # 'command': 'jupyter-notebook-atom:toggle' 18 | # } 19 | # ] 20 | # ] 21 | # } 22 | # ] 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyter-notebook", 3 | "main": "./lib/main", 4 | "version": "0.0.10", 5 | "description": "Jupyter Notebook, but inside Atom.", 6 | "keywords": [ 7 | "jupyter", 8 | "ipython", 9 | "notebook", 10 | "repl" 11 | ], 12 | "repository": "https://github.com/jupyter/atom-notebook", 13 | "license": "MIT", 14 | "engines": { 15 | "atom": ">=1.0.0 <2.0.0" 16 | }, 17 | "dependencies": { 18 | "atom-space-pen-views": "^2.0", 19 | "event-kit": "^1.2", 20 | "flux": "^2", 21 | "fs-plus": "^2.8", 22 | "immutable": "^3", 23 | "jupyter-js-services": ">=0.4.0", 24 | "pathwatcher": ">=4.4.3", 25 | "portfinder": "^0.4.0", 26 | "react": "^0.14", 27 | "react-dom": "^0.14.3", 28 | "transformime": "^1", 29 | "transformime-jupyter-transformers": "0.0.4", 30 | "uuid": "^2", 31 | "ws": "^0.8", 32 | "xmlhttprequest": "^1.8" 33 | }, 34 | "devDependencies": { 35 | "babel-eslint": ">=4.1.5", 36 | "eslint": ">=1.10.1", 37 | "eslint-plugin-react": ">=3.10.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /spec/jupyter-notebook-atom-spec.coffee: -------------------------------------------------------------------------------- 1 | JupyterNotebookAtom = require '../lib/jupyter-notebook-atom' 2 | 3 | # Use the command `window:run-package-specs` (cmd-alt-ctrl-p) to run specs. 4 | # 5 | # To run a specific `it` or `describe` block add an `f` to the front (e.g. `fit` 6 | # or `fdescribe`). Remove the `f` to unfocus the block. 7 | 8 | describe "JupyterNotebookAtom", -> 9 | [workspaceElement, activationPromise] = [] 10 | 11 | beforeEach -> 12 | workspaceElement = atom.views.getView(atom.workspace) 13 | activationPromise = atom.packages.activatePackage('jupyter-notebook-atom') 14 | 15 | describe "when the jupyter-notebook-atom:toggle event is triggered", -> 16 | it "hides and shows the modal panel", -> 17 | # Before the activation event the view is not on the DOM, and no panel 18 | # has been created 19 | expect(workspaceElement.querySelector('.jupyter-notebook-atom')).not.toExist() 20 | 21 | # This is an activation event, triggering it will cause the package to be 22 | # activated. 23 | atom.commands.dispatch workspaceElement, 'jupyter-notebook-atom:toggle' 24 | 25 | waitsForPromise -> 26 | activationPromise 27 | 28 | runs -> 29 | expect(workspaceElement.querySelector('.jupyter-notebook-atom')).toExist() 30 | 31 | jupyterNotebookAtomElement = workspaceElement.querySelector('.jupyter-notebook-atom') 32 | expect(jupyterNotebookAtomElement).toExist() 33 | 34 | jupyterNotebookAtomPanel = atom.workspace.panelForItem(jupyterNotebookAtomElement) 35 | expect(jupyterNotebookAtomPanel.isVisible()).toBe true 36 | atom.commands.dispatch workspaceElement, 'jupyter-notebook-atom:toggle' 37 | expect(jupyterNotebookAtomPanel.isVisible()).toBe false 38 | 39 | it "hides and shows the view", -> 40 | # This test shows you an integration test testing at the view level. 41 | 42 | # Attaching the workspaceElement to the DOM is required to allow the 43 | # `toBeVisible()` matchers to work. Anything testing visibility or focus 44 | # requires that the workspaceElement is on the DOM. Tests that attach the 45 | # workspaceElement to the DOM are generally slower than those off DOM. 46 | jasmine.attachToDOM(workspaceElement) 47 | 48 | expect(workspaceElement.querySelector('.jupyter-notebook-atom')).not.toExist() 49 | 50 | # This is an activation event, triggering it causes the package to be 51 | # activated. 52 | atom.commands.dispatch workspaceElement, 'jupyter-notebook-atom:toggle' 53 | 54 | waitsForPromise -> 55 | activationPromise 56 | 57 | runs -> 58 | # Now we can test for view visibility 59 | jupyterNotebookAtomElement = workspaceElement.querySelector('.jupyter-notebook-atom') 60 | expect(jupyterNotebookAtomElement).toBeVisible() 61 | atom.commands.dispatch workspaceElement, 'jupyter-notebook-atom:toggle' 62 | expect(jupyterNotebookAtomElement).not.toBeVisible() 63 | -------------------------------------------------------------------------------- /spec/jupyter-notebook-atom-view-spec.coffee: -------------------------------------------------------------------------------- 1 | JupyterNotebookAtomView = require '../lib/jupyter-notebook-atom-view' 2 | 3 | describe "JupyterNotebookAtomView", -> 4 | it "has one valid test", -> 5 | expect("life").toBe "easy" 6 | -------------------------------------------------------------------------------- /styles/jupyter-notebook-atom.less: -------------------------------------------------------------------------------- 1 | // The ui-variables file is provided by base themes provided by Atom. 2 | // 3 | // See https://github.com/atom/atom-dark-ui/blob/master/styles/ui-variables.less 4 | // for a full listing of what's available. 5 | @import "ui-variables"; 6 | @import "syntax-variables"; 7 | 8 | .notebook-wrapper { 9 | background-color: @app-background-color; 10 | pointer-events: none; 11 | } 12 | // Everything is inside this, and it doesn't scroll or get pointer events 13 | // This is so that the toolbar can effectively be position: fixed without 14 | // being in danger of overflowing the area (fixed is relative to viewport) 15 | .notebook-editor { 16 | position: relative; 17 | height: 100%; 18 | overflow: hidden; 19 | .notebook-toolbar { 20 | pointer-events: all; 21 | position: absolute; 22 | width: 100%; 23 | top: -1px; 24 | left: 50%; 25 | transform: translateX(-50%); 26 | padding-top: 5px; 27 | padding-bottom: 5px; 28 | text-align: center; 29 | background-color: @tab-background-color-active; 30 | // border-top: 1px solid @tab-border-color; 31 | border-bottom: 1px solid @tab-border-color; 32 | box-shadow: rgba(87, 87, 87, 0.2) 0 4px 5px 0; 33 | z-index: 100; 34 | button { 35 | display: inline-block; 36 | background-color: @button-background-color; 37 | // color: @tab-text-color; 38 | // border: 1px solid @button-border-color; 39 | // &:hover { 40 | // background-color: @button-background-color-hover; 41 | // } 42 | // 43 | // &:active { 44 | // background-color: @button-background-color-selected; 45 | // } 46 | &::before { 47 | margin: 0; 48 | } 49 | } 50 | } 51 | // a full-pane scrollable container 52 | .notebook-cells-container { 53 | pointer-events: all; 54 | height: 100%; 55 | width: 100%; 56 | overflow-y: scroll; 57 | padding: 50px 15px; 58 | } 59 | // actually holds all the cells and is super long 60 | // scrolls through the .notebook-cells-container 61 | .redundant-cells-container { 62 | // max-width: 80%; 63 | margin: 0 auto; 64 | background-color: @tool-panel-background-color; 65 | box-shadow: rgba(87, 87, 87, 0.2) 0 0 12px 1px; 66 | } 67 | .notebook-cell { 68 | width: 100%; 69 | padding: 10px; 70 | margin-bottom: 20px; 71 | display: flex; 72 | flex-direction: row; 73 | // border: 1px solid @base-border-color; 74 | border-radius: 2px; 75 | .cell-main { 76 | // flex-grow: 1; 77 | flex: 1; 78 | } 79 | .execution-count-label { 80 | width: 70px; 81 | margin-right: 5px; 82 | margin-top: 2px; 83 | flex-basis: auto; 84 | flex-grow: 0; 85 | font-size: @font-size; 86 | text-align: right; 87 | font-family: @font-family; 88 | font-weight: 500; 89 | color: @syntax-background-color; 90 | } 91 | // the editor element 92 | .cell-input { 93 | border: 2px solid @syntax-background-color; 94 | border-radius: 2px; 95 | min-height: 3em; 96 | // background-color: lighten(gray, 43%); 97 | padding: 10px; 98 | &.is-focused { 99 | border: 2px solid @syntax-selection-color; 100 | } 101 | } 102 | // the output area in the normal DOM 103 | .cell-display-area { 104 | margin-top: 10px; 105 | padding: 0 10px; 106 | max-height: 80vh; 107 | overflow-y: scroll; 108 | overflow-wrap: break-word; 109 | font-size: @input-font-size; 110 | background-color: #fff; 111 | * { 112 | font-family: monospace; 113 | } 114 | } 115 | } 116 | } 117 | --------------------------------------------------------------------------------