diff --git a/IPython/html/notebookapp.py b/IPython/html/notebookapp.py index e8e920f34..f2d1c53b2 100644 --- a/IPython/html/notebookapp.py +++ b/IPython/html/notebookapp.py @@ -761,6 +761,15 @@ class NotebookApp(BaseIPythonApplication): proto = 'https' if self.certfile else 'http' return "%s://%s:%i%s" % (proto, ip, self.port, self.base_url) + def init_terminals(self): + try: + from .terminal import initialize + initialize(self.web_app) + self.web_app.terminals_available = True + except ImportError as e: + self.log.info("Terminals not available (error was %s)", e) + self.web_app.terminals_available = False + def init_signal(self): if not sys.platform.startswith('win'): signal.signal(signal.SIGINT, self._handle_sigint) @@ -840,6 +849,7 @@ class NotebookApp(BaseIPythonApplication): self.init_configurables() self.init_components() self.init_webapp() + self.init_terminals() self.init_signal() def cleanup_kernels(self): diff --git a/IPython/html/static/notebook/less/terminal.less b/IPython/html/static/notebook/less/terminal.less new file mode 100644 index 000000000..f20cefa9e --- /dev/null +++ b/IPython/html/static/notebook/less/terminal.less @@ -0,0 +1,17 @@ +.terminal { + float: left; + border: black solid 5px; + font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; + font-size: 11px; + color: white; + background: black; +} + +.terminal-cursor { + color: black; + background: white; +} + +#terminado-container { + margin: 8px; +} diff --git a/IPython/html/static/style/style.less b/IPython/html/static/style/style.less index 0f8c37d14..ad5130dc9 100644 --- a/IPython/html/static/style/style.less +++ b/IPython/html/static/style/style.less @@ -26,4 +26,4 @@ // notebook @import "../notebook/less/style.less"; - +@import "../notebook/less/terminal.less"; diff --git a/IPython/html/static/style/style.min.css b/IPython/html/static/style/style.min.css index 8905b2f9b..bdddd754b 100644 --- a/IPython/html/static/style/style.min.css +++ b/IPython/html/static/style/style.min.css @@ -10483,4 +10483,19 @@ span#autosave_status { -ms-transform: rotate(45deg); -o-transform: rotate(45deg); } +.terminal { + float: left; + border: black solid 5px; + font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; + font-size: 11px; + color: white; + background: black; +} +.terminal-cursor { + color: black; + background: white; +} +#terminado-container { + margin: 8px; +} /*# sourceMappingURL=../style/style.min.css.map */ \ No newline at end of file diff --git a/IPython/html/static/terminal/js/main.js b/IPython/html/static/terminal/js/main.js new file mode 100644 index 000000000..12c4e3767 --- /dev/null +++ b/IPython/html/static/terminal/js/main.js @@ -0,0 +1,53 @@ +// Copyright (c) IPython Development Team. +// Distributed under the terms of the Modified BSD License. + +require([ + 'jquery', + 'termjs', + 'base/js/utils', + 'base/js/page', + 'terminal/js/terminado', + 'custom/custom', +], function( + $, + termjs, + utils, + page, + terminado + ){ + page = new page.Page(); + // Test size: 25x80 + var termRowHeight = function(){ return 1.00 * $("#dummy-screen")[0].offsetHeight / 25;}; + // 1.02 here arrived at by trial and error to make the spacing look right + var termColWidth = function() { return 1.02 * $("#dummy-screen-rows")[0].offsetWidth / 80;}; + + var base_url = utils.get_body_data('baseUrl'); + var ws_path = utils.get_body_data('wsPath'); + var ws_url = location.protocol.replace('http', 'ws') + "//" + location.host + + base_url + ws_path; + + var header = $("#header")[0] + function calculate_size() { + height = window.innerHeight - header.offsetHeight; + width = window.innerWidth; + var rows = Math.min(1000, Math.max(20, Math.floor(height/termRowHeight())-1)); + var cols = Math.min(1000, Math.max(40, Math.floor(width/termColWidth())-1)); + console.log("resize to :", rows , 'rows by ', cols, 'columns'); + return {rows: rows, cols: cols}; + } + + page.show_header(); + + size = calculate_size(); + var terminal = terminado.make_terminal($("#terminado-container")[0], size, ws_url); + + page.show_site(); + + window.onresize = function() { + var geom = calculate_size(); + terminal.term.resize(geom.cols, geom.rows); + terminal.socket.send(JSON.stringify(["set_size", geom.rows, geom.cols, + window.innerHeight, window.innerWidth])); + }; + +}); diff --git a/IPython/html/static/terminal/js/terminado.js b/IPython/html/static/terminal/js/terminado.js new file mode 100644 index 000000000..2fda37a8c --- /dev/null +++ b/IPython/html/static/terminal/js/terminado.js @@ -0,0 +1,39 @@ +define ([], function() { + function make_terminal(element, size, ws_url) { + var ws = new WebSocket(ws_url); + var term = new Terminal({ + cols: size.cols, + rows: size.rows, + screenKeys: true, + useStyle: false + }); + ws.onopen = function(event) { + ws.send(JSON.stringify(["set_size", size.rows, size.cols, + window.innerHeight, window.innerWidth])); + term.on('data', function(data) { + ws.send(JSON.stringify(['stdin', data])); + }); + + term.on('title', function(title) { + document.title = title; + }); + + term.open(element); + + ws.onmessage = function(event) { + json_msg = JSON.parse(event.data); + switch(json_msg[0]) { + case "stdout": + term.write(json_msg[1]); + break; + case "disconnect": + term.write("\r\n\r\n[CLOSED]\r\n"); + break; + } + }; + }; + return {socket: ws, term: term}; + } + + return {make_terminal: make_terminal}; +}); diff --git a/IPython/html/templates/page.html b/IPython/html/templates/page.html index cc8adb1b2..b3c67b821 100644 --- a/IPython/html/templates/page.html +++ b/IPython/html/templates/page.html @@ -29,6 +29,7 @@ highlight: 'components/highlight.js/build/highlight.pack', moment: "components/moment/moment", codemirror: 'components/codemirror', + termjs: "components/term.js/src/term" }, shim: { underscore: { diff --git a/IPython/html/templates/terminal.html b/IPython/html/templates/terminal.html new file mode 100644 index 000000000..aa32d0479 --- /dev/null +++ b/IPython/html/templates/terminal.html @@ -0,0 +1,57 @@ +{% extends "page.html" %} + +{% block title %}{{page_title}}{% endblock %} + +{% block params %} + +data-base-url="{{base_url}}" +data-ws-path="{{ws_path}}" + +{% endblock %} + + +{% block site %} + +
+ +{% endblock %} + +{% block script %} + + + +
+
0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+0
+1
+2
+3
+01234567890123456789012345678901234567890123456789012345678901234567890123456789
+
+
+ + {{super()}} + + +{% endblock %} diff --git a/IPython/html/terminal/__init__.py b/IPython/html/terminal/__init__.py new file mode 100644 index 000000000..e4e33a29a --- /dev/null +++ b/IPython/html/terminal/__init__.py @@ -0,0 +1,19 @@ +import os +from terminado import NamedTermManager +from IPython.html.utils import url_path_join as ujoin +from .handlers import TerminalHandler, NewTerminalHandler, TermSocket +from . import api_handlers + +def initialize(webapp): + shell = os.environ.get('SHELL', 'sh') + webapp.terminal_manager = NamedTermManager(shell_command=[shell]) + base_url = webapp.settings['base_url'] + handlers = [ + (ujoin(base_url, "/terminals/new"), NewTerminalHandler), + (ujoin(base_url, r"/terminals/(\w+)"), TerminalHandler), + (ujoin(base_url, r"/terminals/websocket/(\w+)"), TermSocket, + {'term_manager': webapp.terminal_manager}), + (ujoin(base_url, r"/api/terminals"), api_handlers.TerminalRootHandler), + (ujoin(base_url, r"/api/terminals/(\w+)"), api_handlers.TerminalHandler), + ] + webapp.add_handlers(".*$", handlers) \ No newline at end of file diff --git a/IPython/html/terminal/api_handlers.py b/IPython/html/terminal/api_handlers.py new file mode 100644 index 000000000..90fcd22d0 --- /dev/null +++ b/IPython/html/terminal/api_handlers.py @@ -0,0 +1,35 @@ +import json +from tornado import web +from ..base.handlers import IPythonHandler, json_errors + +class TerminalRootHandler(IPythonHandler): + @web.authenticated + @json_errors + def get(self): + tm = self.application.terminal_manager + terms = [{'name': name} for name in tm.terminals] + self.finish(json.dumps(terms)) + +class TerminalHandler(IPythonHandler): + SUPPORTED_METHODS = ('GET', 'DELETE') + + @web.authenticated + @json_errors + def get(self, name): + tm = self.application.terminal_manager + if name in tm.terminals: + self.finish(json.dumps({'name': name})) + else: + raise web.HTTPError(404, "Terminal not found: %r" % name) + + @web.authenticated + @json_errors + def delete(self, name): + tm = self.application.terminal_manager + if name in tm.terminals: + tm.kill(name) + # XXX: Should this wait for terminal to finish before returning? + self.set_status(204) + self.finish() + else: + raise web.HTTPError(404, "Terminal not found: %r" % name) \ No newline at end of file diff --git a/IPython/html/terminal/handlers.py b/IPython/html/terminal/handlers.py new file mode 100644 index 000000000..b0e2e4f4b --- /dev/null +++ b/IPython/html/terminal/handlers.py @@ -0,0 +1,43 @@ +"""Tornado handlers for the terminal emulator.""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. + +import tornado +from tornado import web +import terminado +from ..base.handlers import IPythonHandler + +class TerminalHandler(IPythonHandler): + """Render the terminal interface.""" + @web.authenticated + def get(self, term_name): + self.write(self.render_template('terminal.html', + ws_path="terminals/websocket/%s" % term_name)) + +class NewTerminalHandler(IPythonHandler): + """Redirect to a new terminal.""" + @web.authenticated + def get(self): + name, _ = self.application.terminal_manager.new_named_terminal() + self.redirect(name, permanent=False) + +class TermSocket(terminado.TermSocket, IPythonHandler): + def get(self, *args, **kwargs): + if not self.get_current_user(): + raise web.HTTPError(403) + + # FIXME: only do super get on tornado ≥ 4 + # tornado 3 has no get, will raise 405 + if tornado.version_info >= (4,): + return super(TermSocket, self).get(*args, **kwargs) + + def open(self, *args, **kwargs): + if tornado.version_info < (4,): + try: + self.get(*self.open_args, **self.open_kwargs) + except web.HTTPError: + self.close() + raise + + super(TermSocket, self).open(*args, **kwargs) diff --git a/IPython/testing/iptest.py b/IPython/testing/iptest.py index 50876c5ab..1f0ade979 100644 --- a/IPython/testing/iptest.py +++ b/IPython/testing/iptest.py @@ -137,6 +137,7 @@ have['mistune'] = test_for('mistune') have['requests'] = test_for('requests') have['sphinx'] = test_for('sphinx') have['jsonschema'] = test_for('jsonschema') +have['terminado'] = test_for('terminado') have['casperjs'] = is_cmd_found('casperjs') have['phantomjs'] = is_cmd_found('phantomjs') have['slimerjs'] = is_cmd_found('slimerjs') @@ -264,6 +265,8 @@ if not have['jinja2']: sec.exclude('notebookapp') if not have['pygments'] or not have['jinja2']: sec.exclude('nbconvert') +if not have['terminado']: + sec.exclude('terminal') # config: # Config files aren't really importable stand-alone diff --git a/setupbase.py b/setupbase.py index 7a9da5e18..fd745d033 100644 --- a/setupbase.py +++ b/setupbase.py @@ -162,6 +162,7 @@ def find_package_data(): pjoin(components, "underscore", "underscore-min.js"), pjoin(components, "moment", "moment.js"), pjoin(components, "moment", "min", "moment.min.js"), + pjoin(components, "term.js", "src", "term.js"), pjoin(components, "text-encoding", "lib", "encoding.js"), ])