diff --git a/.travis.yml b/.travis.yml index 99ae90699..7463d827e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ # http://travis-ci.org/#!/ipython/ipython language: python +group: edge cache: directories: - ~/.cache/bower diff --git a/docs/source/extending/frontend_extensions.rst b/docs/source/extending/frontend_extensions.rst new file mode 100644 index 000000000..e23dfdd53 --- /dev/null +++ b/docs/source/extending/frontend_extensions.rst @@ -0,0 +1,224 @@ +Custom front-end extensions +=========================== + +This describes the basic steps to write a JavaScript extension for the Jupyter +notebook front-end. This allows you to customize the behaviour of the various +pages like the dashboard, the notebook, or the text editor. + +The structure of a front-end extension +-------------------------------------- + +.. note:: + + The notebook front-end and Javascript API are not stable, and are subject + to a lot of changes. Any extension written for the current notebook is + almost guaranteed to break in the next release. + +.. _AMD module: https://en.wikipedia.org/wiki/Asynchronous_module_definition + +A front-end extension is a JavaScript file that defines an `AMD module`_ +which exposes at least a function called ``load_ipython_extension``, which +takes no arguments. We will not get into the details of what each of these +terms consists of yet, but here is the minimal code needed for a working +extension: + +.. code:: javascript + + // file my_extension/main.js + + define(function(){ + + function load_ipython_extension(){ + console.info('this is my first extension'); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + +.. note:: + + Although for historical reasons the function is called + ``load_ipython_extension``, it does apply to the Jupyter notebook in + general, and will work regardless of the kernel in use. + +If you are familiar with JavaScript, you can use this template to require any +Jupyter module and modify its configuration, or do anything else in client-side +Javascript. Your extension will be loaded at the right time during the notebook +page initialisation for you to set up a listener for the various events that +the page can trigger. + +You might want access to the current instances of the various Jupyter notebook +components on the page, as opposed to the classes defined in the modules. The +current instances are exposed by a module named ``base/js/namespace``. If you +plan on accessing instances on the page, you should ``require`` this module +rather than accessing the global variable ``Jupyter``, which will be removed in +future. The following example demonstrates how to access the current notebook +instance: + +.. code:: javascript + + // file my_extension/main.js + + define([ + 'base/js/namespace' + ], function( + Jupyter + ) { + function load_ipython_extension() { + console.log( + 'This is the current notebook application instance:', + Jupyter.notebook + ); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + + +Modifying key bindings +---------------------- + +One of the abilities of extensions is to modify key bindings, although once +again this is an API which is not guaranteed to be stable. However, custom key +bindings are frequently requested, and are helpful to increase accessibility, +so in the following we show how to access them. + +Here is an example of an extension that will unbind the shortcut ``0,0`` in +command mode, which normally restarts the kernel, and bind ``0,0,0`` in its +place: + +.. code:: javascript + + // file my_extension/main.js + + define([ + 'base/js/namespace' + ], function( + Jupyter + ) { + + function load_ipython_extension() { + Jupyter.keyboard_manager.command_shortcuts.remove_shortcut('0,0'); + Jupyter.keyboard_manager.command_shortcuts.add_shortcut('0,0,0', 'jupyter-notebook:restart-kernel'); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + +.. note:: + + The standard keybindings might not work correctly on non-US keyboards. + Unfortunately, this is a limitation of browser implementations and the + status of keyboard event handling on the web in general. We appreciate your + feedback if you have issues binding keys, or have any ideas to help improve + the situation. + +You can see that I have used the **action name** +``jupyter-notebook:restart-kernel`` to bind the new shortcut. There is no API +yet to access the list of all available *actions*, though the following in the +JavaScript console of your browser on a notebook page should give you an idea +of what is available: + +.. code:: javascript + + Object.keys(require('base/js/namespace').actions._actions); + +In this example, we changed a keyboard shortcut in **command mode**; you +can also customize keyboard shortcuts in **edit mode**. +However, most of the keyboard shortcuts in edit mode are handled by CodeMirror, +which supports custom key bindings via a completely different API. + + +Defining and registering your own actions +----------------------------------------- + +As part of your front-end extension, you may wish to define actions, which can +be attached to toolbar buttons, or called from the command palette. Here is an +example of an extension that defines a (not very useful!) action to show an +alert, and adds a toolabr button using the full action name: + +.. code:: javascript + + // file my_extension/main.js + + define([ + 'base/js/namespace' + ], function( + Jupyter + ) { + function load_ipython_extension() { + + var handler = function () { + alert('this is an alert from my_extension!'); + }; + + var action = { + icon: 'fa-comment-o', // a font-awesome class used on buttons, etc + help : 'Show an alert', + help_index : 'zz', + handler : handler + }; + var prefix = 'my_extension'; + var action_name = 'show-alert'; + + var full_action_name = Jupyter.actions.register(action, name, prefix); // returns 'my_extension:show-alert' + Jupyter.toolbar.add_buttons_group([full_action_name]); + } + + return { + load_ipython_extension: load_ipython_extension + }; + }); + +Every action needs a name, which, when joined with its prefix to make the full +action name, should be unique. Built-in actions, like the +``jupyter-notebook:restart-kernel`` we bound in the earlier +`Modifying key bindings`_ example, use the prefix ``jupyter-notebook``. For +actions defined in an extension, it makes sense to use the extension name as +the prefix. For the action name, the following guidelines should be considered: + +.. adapted from notebook/static/notebook/js/actions.js +* First pick a noun and a verb for the action. For example, if the action is + "restart kernel," the verb is "restart" and the noun is "kernel". +* Omit terms like "selected" and "active" by default, so "delete-cell", rather + than "delete-selected-cell". Only provide a scope like "-all-" if it is other + than the default "selected" or "active" scope. +* If an action has a secondary action, separate the secondary action with + "-and-", so "restart-kernel-and-clear-output". +* Use above/below or previous/next to indicate spatial and sequential + relationships. +* Don't ever use before/after as they have a temporal connotation that is + confusing when used in a spatial context. +* For dialogs, use a verb that indicates what the dialog will accomplish, such + as "confirm-restart-kernel". + + +Installing and enabling extensions +---------------------------------- + +You can install your nbextension with the command: + + jupyter nbextension install path/to/my_extension/ + +Where my_extension is the directory containing the Javascript files. +This will copy it to a Jupyter data directory (the exact location is platform +dependent - see :ref:`jupyter_path`). + +For development, you can use the ``--symlink`` flag to symlink your extension +rather than copying it, so there's no need to reinstall after changes. + +To use your extension, you'll also need to **enable** it, which tells the +notebook interface to load it. You can do that with another command: + + jupyter nbextension enable my_extension/main + +The argument refers to the Javascript module containing your +``load_ipython_extension`` function, which is ``my_extension/main.js`` in this +example. There is a corresponding ``disable`` command to stop using an +extension without uninstalling it. diff --git a/notebook/log.py b/notebook/log.py index eecd39a00..dab330cb4 100644 --- a/notebook/log.py +++ b/notebook/log.py @@ -43,6 +43,6 @@ def log_request(handler): msg = msg + ' referer={referer}' if status >= 500 and status != 502: # log all headers if it caused an error - log_method(json.dumps(request.headers, indent=2)) + log_method(json.dumps(dict(request.headers), indent=2)) log_method(msg.format(**ns)) diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index c9badd0b8..2c1d392d7 100644 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -120,7 +120,7 @@ class DeprecationHandler(IPythonHandler): def get(self, url_path): self.set_header("Content-Type", 'text/javascript') self.finish(""" - console.warn('`/static/widgets/js` is deprecated. Use `/nbextensions/widgets/widgets/js` instead.'); + console.warn('`/static/widgets/js` is deprecated. Use `nbextensions/widgets/widgets/js` instead.'); define(['%s'], function(x) { return x; }); """ % url_path_join('nbextensions', 'widgets', 'widgets', url_path.rstrip('.js'))) self.log.warn('Deprecated widget Javascript path /static/widgets/js/*.js was used') diff --git a/notebook/services/contents/tests/test_manager.py b/notebook/services/contents/tests/test_manager.py index 3ff2c805f..326280219 100644 --- a/notebook/services/contents/tests/test_manager.py +++ b/notebook/services/contents/tests/test_manager.py @@ -6,6 +6,7 @@ import os import sys import time from contextlib import contextmanager +from itertools import combinations from nose import SkipTest from tornado.web import HTTPError @@ -188,6 +189,41 @@ class TestFileContentsManager(TestCase): class TestContentsManager(TestCase): + @contextmanager + def assertRaisesHTTPError(self, status, msg=None): + msg = msg or "Should have raised HTTPError(%i)" % status + try: + yield + except HTTPError as e: + self.assertEqual(e.status_code, status) + else: + self.fail(msg) + + def make_populated_dir(self, api_path): + cm = self.contents_manager + + self.make_dir(api_path) + + cm.new(path="/".join([api_path, "nb.ipynb"])) + cm.new(path="/".join([api_path, "file.txt"])) + + def check_populated_dir_files(self, api_path): + dir_model = self.contents_manager.get(api_path) + + self.assertEqual(dir_model['path'], api_path) + self.assertEqual(dir_model['type'], "directory") + + for entry in dir_model['content']: + if entry['type'] == "directory": + continue + elif entry['type'] == "file": + self.assertEqual(entry['name'], "file.txt") + complete_path = "/".join([api_path, "file.txt"]) + self.assertEqual(entry["path"], complete_path) + elif entry['type'] == "notebook": + self.assertEqual(entry['name'], "nb.ipynb") + complete_path = "/".join([api_path, "nb.ipynb"]) + self.assertEqual(entry["path"], complete_path) def setUp(self): self._temp_dir = TemporaryDirectory() @@ -460,6 +496,55 @@ class TestContentsManager(TestCase): # Check that a 'get' on the deleted notebook raises and error self.assertRaises(HTTPError, cm.get, path) + def test_rename(self): + cm = self.contents_manager + # Create a new notebook + nb, name, path = self.new_notebook() + + # Rename the notebook + cm.rename(path, "changed_path") + + # Attempting to get the notebook under the old name raises an error + self.assertRaises(HTTPError, cm.get, path) + # Fetching the notebook under the new name is successful + assert isinstance(cm.get("changed_path"), dict) + + # Ported tests on nested directory renaming from pgcontents + all_dirs = ['foo', 'bar', 'foo/bar', 'foo/bar/foo', 'foo/bar/foo/bar'] + unchanged_dirs = all_dirs[:2] + changed_dirs = all_dirs[2:] + + for _dir in all_dirs: + self.make_populated_dir(_dir) + self.check_populated_dir_files(_dir) + + # Renaming to an existing directory should fail + for src, dest in combinations(all_dirs, 2): + with self.assertRaisesHTTPError(409): + cm.rename(src, dest) + + # Creating a notebook in a non_existant directory should fail + with self.assertRaisesHTTPError(404): + cm.new_untitled("foo/bar_diff", ext=".ipynb") + + cm.rename("foo/bar", "foo/bar_diff") + + # Assert that unchanged directories remain so + for unchanged in unchanged_dirs: + self.check_populated_dir_files(unchanged) + + # Assert changed directories can no longer be accessed under old names + for changed_dirname in changed_dirs: + with self.assertRaisesHTTPError(404): + cm.get(changed_dirname) + + new_dirname = changed_dirname.replace("foo/bar", "foo/bar_diff", 1) + + self.check_populated_dir_files(new_dirname) + + # Created a notebook in the renamed directory should work + cm.new_untitled("foo/bar_diff", ext=".ipynb") + def test_delete_root(self): cm = self.contents_manager with self.assertRaises(HTTPError) as err: diff --git a/notebook/services/kernels/handlers.py b/notebook/services/kernels/handlers.py index 19bc20315..0da36714f 100644 --- a/notebook/services/kernels/handlers.py +++ b/notebook/services/kernels/handlers.py @@ -25,12 +25,15 @@ class MainKernelHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def get(self): km = self.kernel_manager - self.finish(json.dumps(km.list_kernels())) + kernels = yield gen.maybe_future(km.list_kernels()) + self.finish(json.dumps(kernels)) @web.authenticated @json_errors + @gen.coroutine def post(self): km = self.kernel_manager model = self.get_json_body() @@ -41,7 +44,7 @@ class MainKernelHandler(APIHandler): else: model.setdefault('name', km.default_kernel_name) - kernel_id = km.start_kernel(kernel_name=model['name']) + kernel_id = yield gen.maybe_future(km.start_kernel(kernel_name=model['name'])) model = km.kernel_model(kernel_id) location = url_path_join(self.base_url, 'api', 'kernels', url_escape(kernel_id)) self.set_header('Location', location) @@ -61,9 +64,10 @@ class KernelHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def delete(self, kernel_id): km = self.kernel_manager - km.shutdown_kernel(kernel_id) + yield gen.maybe_future(km.shutdown_kernel(kernel_id)) self.set_status(204) self.finish() diff --git a/notebook/services/kernels/kernelmanager.py b/notebook/services/kernels/kernelmanager.py index d08d2ba8d..ccf2738a7 100644 --- a/notebook/services/kernels/kernelmanager.py +++ b/notebook/services/kernels/kernelmanager.py @@ -62,7 +62,8 @@ class MappingKernelManager(MultiKernelManager): while not os.path.isdir(os_path) and os_path != self.root_dir: os_path = os.path.dirname(os_path) return os_path - + + @gen.coroutine def start_kernel(self, kernel_id=None, path=None, **kwargs): """Start a kernel for a session and return its kernel_id. @@ -82,8 +83,9 @@ class MappingKernelManager(MultiKernelManager): if kernel_id is None: if path is not None: kwargs['cwd'] = self.cwd_for_path(path) - kernel_id = super(MappingKernelManager, self).start_kernel( - **kwargs) + kernel_id = yield gen.maybe_future( + super(MappingKernelManager, self).start_kernel(**kwargs) + ) self.log.info("Kernel started: %s" % kernel_id) self.log.debug("Kernel args: %r" % kwargs) # register callback for failed auto-restart @@ -94,12 +96,13 @@ class MappingKernelManager(MultiKernelManager): else: self._check_kernel_id(kernel_id) self.log.info("Using existing kernel: %s" % kernel_id) - return kernel_id + # py2-compat + raise gen.Return(kernel_id) def shutdown_kernel(self, kernel_id, now=False): """Shutdown a kernel by kernel_id""" self._check_kernel_id(kernel_id) - super(MappingKernelManager, self).shutdown_kernel(kernel_id, now=now) + return super(MappingKernelManager, self).shutdown_kernel(kernel_id, now=now) def restart_kernel(self, kernel_id): """Restart a kernel by kernel_id""" diff --git a/notebook/services/sessions/handlers.py b/notebook/services/sessions/handlers.py index 4c46c45df..b7a7f11de 100644 --- a/notebook/services/sessions/handlers.py +++ b/notebook/services/sessions/handlers.py @@ -8,7 +8,7 @@ Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-16%3A- import json -from tornado import web +from tornado import gen, web from ...base.handlers import APIHandler, json_errors from jupyter_client.jsonutil import date_default @@ -20,20 +20,20 @@ class SessionRootHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def get(self): # Return a list of running sessions sm = self.session_manager - sessions = sm.list_sessions() + sessions = yield gen.maybe_future(sm.list_sessions()) self.finish(json.dumps(sessions, default=date_default)) @web.authenticated @json_errors + @gen.coroutine def post(self): # Creates a new session #(unless a session already exists for the named nb) sm = self.session_manager - cm = self.contents_manager - km = self.kernel_manager model = self.get_json_body() if model is None: @@ -49,11 +49,13 @@ class SessionRootHandler(APIHandler): kernel_name = None # Check to see if session exists - if sm.session_exists(path=path): - model = sm.get_session(path=path) + exists = yield gen.maybe_future(sm.session_exists(path=path)) + if exists: + model = yield gen.maybe_future(sm.get_session(path=path)) else: try: - model = sm.create_session(path=path, kernel_name=kernel_name) + model = yield gen.maybe_future( + sm.create_session(path=path, kernel_name=kernel_name)) except NoSuchKernel: msg = ("The '%s' kernel is not available. Please pick another " "suitable kernel instead, or install that kernel." % kernel_name) @@ -73,14 +75,16 @@ class SessionHandler(APIHandler): @web.authenticated @json_errors + @gen.coroutine def get(self, session_id): # Returns the JSON model for a single session sm = self.session_manager - model = sm.get_session(session_id=session_id) + model = yield gen.maybe_future(sm.get_session(session_id=session_id)) self.finish(json.dumps(model, default=date_default)) @web.authenticated @json_errors + @gen.coroutine def patch(self, session_id): # Currently, this handler is strictly for renaming notebooks sm = self.session_manager @@ -93,17 +97,18 @@ class SessionHandler(APIHandler): if 'path' in notebook: changes['path'] = notebook['path'] - sm.update_session(session_id, **changes) - model = sm.get_session(session_id=session_id) + yield gen.maybe_future(sm.update_session(session_id, **changes)) + model = yield gen.maybe_future(sm.get_session(session_id=session_id)) self.finish(json.dumps(model, default=date_default)) @web.authenticated @json_errors + @gen.coroutine def delete(self, session_id): # Deletes the session with given session_id sm = self.session_manager try: - sm.delete_session(session_id) + yield gen.maybe_future(sm.delete_session(session_id)) except KeyError: # the kernel was deleted but the session wasn't! raise web.HTTPError(410, "Kernel deleted before session") diff --git a/notebook/services/sessions/sessionmanager.py b/notebook/services/sessions/sessionmanager.py index b75732087..125940408 100644 --- a/notebook/services/sessions/sessionmanager.py +++ b/notebook/services/sessions/sessionmanager.py @@ -6,7 +6,7 @@ import uuid import sqlite3 -from tornado import web +from tornado import gen, web from traitlets.config.configurable import LoggingConfigurable from ipython_genutils.py3compat import unicode_type @@ -39,10 +39,16 @@ class SessionManager(LoggingConfigurable): self._connection = sqlite3.connect(':memory:') self._connection.row_factory = sqlite3.Row return self._connection - + + def close(self): + """Close the sqlite connection""" + if self._cursor is not None: + self._cursor.close() + self._cursor = None + def __del__(self): """Close connection once SessionManager closes""" - self.cursor.close() + self.close() def session_exists(self, path): """Check to see if the session for a given notebook exists""" @@ -56,17 +62,22 @@ class SessionManager(LoggingConfigurable): def new_session_id(self): "Create a uuid for a new session" return unicode_type(uuid.uuid4()) - + + @gen.coroutine def create_session(self, path=None, kernel_name=None): """Creates a session and returns its model""" session_id = self.new_session_id() # allow nbm to specify kernels cwd kernel_path = self.contents_manager.get_kernel_path(path=path) - kernel_id = self.kernel_manager.start_kernel(path=kernel_path, - kernel_name=kernel_name) - return self.save_session(session_id, path=path, - kernel_id=kernel_id) - + kernel_id = yield gen.maybe_future( + self.kernel_manager.start_kernel(path=kernel_path, kernel_name=kernel_name) + ) + result = yield gen.maybe_future( + self.save_session(session_id, path=path, kernel_id=kernel_id) + ) + # py2-compat + raise gen.Return(result) + def save_session(self, session_id, path=None, kernel_id=None): """Saves the items for the session with the given session_id diff --git a/notebook/services/sessions/tests/test_sessionmanager.py b/notebook/services/sessions/tests/test_sessionmanager.py index d1382ed1d..0b31d97a4 100644 --- a/notebook/services/sessions/tests/test_sessionmanager.py +++ b/notebook/services/sessions/tests/test_sessionmanager.py @@ -2,7 +2,8 @@ from unittest import TestCase -from tornado import web +from tornado import gen, web +from tornado.ioloop import IOLoop from ..sessionmanager import SessionManager from notebook.services.kernels.kernelmanager import MappingKernelManager @@ -37,11 +38,27 @@ class TestSessionManager(TestCase): kernel_manager=DummyMKM(), contents_manager=ContentsManager(), ) - + self.loop = IOLoop() + + def tearDown(self): + self.loop.close(all_fds=True) + + def create_sessions(self, *kwarg_list): + @gen.coroutine + def co_add(): + sessions = [] + for kwargs in kwarg_list: + session = yield self.sm.create_session(**kwargs) + sessions.append(session) + raise gen.Return(sessions) + return self.loop.run_sync(co_add) + + def create_session(self, **kwargs): + return self.create_sessions(kwargs)[0] + def test_get_session(self): sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', - kernel_name='bar')['id'] + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='bar')['id'] model = sm.get_session(session_id=session_id) expected = {'id':session_id, 'notebook':{'path': u'/path/to/test.ipynb'}, @@ -51,13 +68,13 @@ class TestSessionManager(TestCase): def test_bad_get_session(self): # Should raise error if a bad key is passed to the database. sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='foo')['id'] self.assertRaises(TypeError, sm.get_session, bad_id=session_id) # Bad keyword def test_get_session_dead_kernel(self): sm = self.sm - session = sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python') + session = self.create_session(path='/path/to/1/test1.ipynb', kernel_name='python') # kill the kernel sm.kernel_manager.shutdown_kernel(session['kernel']['id']) with self.assertRaises(KeyError): @@ -68,11 +85,12 @@ class TestSessionManager(TestCase): def test_list_sessions(self): sm = self.sm - sessions = [ - sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/3/test3.ipynb', kernel_name='python'), - ] + sessions = self.create_sessions( + dict(path='/path/to/1/test1.ipynb', kernel_name='python'), + dict(path='/path/to/2/test2.ipynb', kernel_name='python'), + dict(path='/path/to/3/test3.ipynb', kernel_name='python'), + ) + sessions = sm.list_sessions() expected = [ { @@ -93,10 +111,10 @@ class TestSessionManager(TestCase): def test_list_sessions_dead_kernel(self): sm = self.sm - sessions = [ - sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), - ] + sessions = self.create_sessions( + dict(path='/path/to/1/test1.ipynb', kernel_name='python'), + dict(path='/path/to/2/test2.ipynb', kernel_name='python'), + ) # kill one of the kernels sm.kernel_manager.shutdown_kernel(sessions[0]['kernel']['id']) listed = sm.list_sessions() @@ -116,7 +134,7 @@ class TestSessionManager(TestCase): def test_update_session(self): sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='julia')['id'] sm.update_session(session_id, path='/path/to/new_name.ipynb') model = sm.get_session(session_id=session_id) @@ -128,17 +146,17 @@ class TestSessionManager(TestCase): def test_bad_update_session(self): # try to update a session with a bad keyword ~ raise error sm = self.sm - session_id = sm.create_session(path='/path/to/test.ipynb', + session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='ir')['id'] self.assertRaises(TypeError, sm.update_session, session_id=session_id, bad_kw='test.ipynb') # Bad keyword def test_delete_session(self): sm = self.sm - sessions = [ - sm.create_session(path='/path/to/1/test1.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/2/test2.ipynb', kernel_name='python'), - sm.create_session(path='/path/to/3/test3.ipynb', kernel_name='python'), - ] + sessions = self.create_sessions( + dict(path='/path/to/1/test1.ipynb', kernel_name='python'), + dict(path='/path/to/2/test2.ipynb', kernel_name='python'), + dict(path='/path/to/3/test3.ipynb', kernel_name='python'), + ) sm.delete_session(sessions[1]['id']) new_sessions = sm.list_sessions() expected = [{ @@ -156,7 +174,7 @@ class TestSessionManager(TestCase): def test_bad_delete_session(self): # try to delete a session that doesn't exist ~ raise error sm = self.sm - sm.create_session(path='/path/to/test.ipynb', kernel_name='python') + self.create_session(path='/path/to/test.ipynb', kernel_name='python') self.assertRaises(TypeError, sm.delete_session, bad_kwarg='23424') # Bad keyword self.assertRaises(web.HTTPError, sm.delete_session, session_id='23424') # nonexistant diff --git a/notebook/static/base/js/dialog.js b/notebook/static/base/js/dialog.js index 14212bfa5..769817410 100644 --- a/notebook/static/base/js/dialog.js +++ b/notebook/static/base/js/dialog.js @@ -75,6 +75,8 @@ define(function(require) { var footer = $("
").addClass("modal-footer"); + var default_button; + for (var label in options.buttons) { var btn_opts = options.buttons[label]; var button = $("") @@ -88,12 +90,18 @@ define(function(require) { button.addClass(btn_opts.class); } footer.append(button); + if (options.default_button && label === options.default_button) { + default_button = button; + } + } + if (!options.default_button) { + default_button = footer.find("button").last(); } dialog_content.append(footer); // hook up on-open event - modal.on("shown.bs.modal", function() { - setTimeout(function() { - footer.find("button").last().focus(); + modal.on("shown.bs.modal", function () { + setTimeout(function () { + default_button.focus(); if (options.open) { $.proxy(options.open, modal)(); } diff --git a/notebook/static/base/js/keyboard.js b/notebook/static/base/js/keyboard.js index 2259cd126..ee8f2538b 100644 --- a/notebook/static/base/js/keyboard.js +++ b/notebook/static/base/js/keyboard.js @@ -255,7 +255,13 @@ define([ } help.sort(function (a, b) { if (a.help_index === b.help_index) { - return 0; + if (a.shortcut === b.shortcut) { + return 0; + } + if (a.shortcut > b.shortcut) { + return 1; + } + return -1; } if (a.help_index === undefined || a.help_index > b.help_index){ return 1; @@ -362,7 +368,7 @@ define([ **/ var action_name = this.actions.get_name(data); if (! action_name){ - throw new Error('does not know how to deal with', data); + throw new Error('does not know how to deal with : ' + data); } shortcut = normalize_shortcut(shortcut); this.set_shortcut(shortcut, action_name); diff --git a/notebook/static/notebook/js/actions.js b/notebook/static/notebook/js/actions.js index 2b05f7603..b39a68902 100644 --- a/notebook/static/notebook/js/actions.js +++ b/notebook/static/notebook/js/actions.js @@ -107,10 +107,10 @@ define(function(require){ } }, 'run-cell':{ - help : 'run marked cells', + help : 'run selected cells', help_index : 'bb', handler : function (env) { - env.notebook.execute_marked_cells(); + env.notebook.execute_selected_cells(); } }, 'run-cell-and-insert-below':{ @@ -163,7 +163,7 @@ define(function(require){ handler : function (env) { var index = env.notebook.get_selected_index(); if (index !== 0 && index !== null) { - env.notebook.select_prev(); + env.notebook.select_prev(true); env.notebook.focus_cell(); } } @@ -174,23 +174,23 @@ define(function(require){ handler : function (env) { var index = env.notebook.get_selected_index(); if (index !== (env.notebook.ncells()-1) && index !== null) { - env.notebook.select_next(); + env.notebook.select_next(true); env.notebook.focus_cell(); } } }, - 'extend-marked-cells-above' : { - help: 'extend marked cells above', + 'extend-selection-above' : { + help: 'extend selected cells above', help_index : 'dc', handler : function (env) { - env.notebook.extend_marked(-1); + env.notebook.extend_selection_by(-1) } }, - 'extend-marked-cells-below' : { - help: 'extend marked cells below', + 'extend-selection-below' : { + help: 'extend selected cells below', help_index : 'dd', handler : function (env) { - env.notebook.extend_marked(1); + env.notebook.extend_selection_by(1) } }, 'cut-cell' : { @@ -229,7 +229,7 @@ define(function(require){ help_index : 'ec', handler : function (env) { env.notebook.insert_cell_above(); - env.notebook.select_prev(); + env.notebook.select_prev(true); env.notebook.focus_cell(); } }, @@ -239,7 +239,7 @@ define(function(require){ help_index : 'ed', handler : function (env) { env.notebook.insert_cell_below(); - env.notebook.select_next(); + env.notebook.select_next(true); env.notebook.focus_cell(); } }, @@ -380,16 +380,10 @@ define(function(require){ } }, 'merge-cells' : { - help : 'merge marked cells', + help : 'merge selected cells', help_index: 'el', handler: function(env) { - env.notebook.merge_marked_cells(); - } - }, - 'close-pager' : { - help_index : 'gd', - handler : function (env) { - env.pager.collapse(); + env.notebook.merge_selected_cells(); } }, 'show-command-palette': { @@ -400,29 +394,6 @@ define(function(require){ env.notebook.show_command_palette(); } }, - 'toggle-cell-marked': { - help_index : 'cj', - help: 'toggle marks', - icon: 'fa-check', - handler : function(env){ - // Use bitwise logic to toggle the marked state. - env.notebook.get_selected_cell().marked ^= true; - } - }, - 'unmark-all-cells': { - help_index : 'ck', - help : 'unmark all cells', - handler : function(env) { - env.notebook.unmark_all_cells(); - } - }, - 'mark-all-cells': { - help_index : 'cl', - help : 'mark all cells', - handler : function(env) { - env.notebook.mark_all_cells(); - } - }, 'toggle-toolbar':{ help: 'hide/show the toolbar', handler : function(env){ @@ -438,14 +409,12 @@ define(function(require){ events.trigger('resize-header.Page'); } }, - 'close-pager-or-unmark-all-cells': { - help : 'close the pager or unmark all cells', + 'close-pager': { + help : 'close the pager', handler : function(env) { - // Collapse the page if it is open, otherwise unmark all. + // Collapse the page if it is open if (env.pager && env.pager.expanded) { env.pager.collapse(); - } else { - env.notebook.unmark_all_cells(); } } }, @@ -481,7 +450,7 @@ define(function(require){ event.preventDefault(); } env.notebook.command_mode(); - env.notebook.select_prev(); + env.notebook.select_prev(true); env.notebook.edit_mode(); cm = env.notebook.get_selected_cell().code_mirror; cm.setCursor(cm.lastLine(), 0); @@ -498,7 +467,7 @@ define(function(require){ event.preventDefault(); } env.notebook.command_mode(); - env.notebook.select_next(); + env.notebook.select_next(true); env.notebook.edit_mode(); var cm = env.notebook.get_selected_cell().code_mirror; cm.setCursor(0, 0); diff --git a/notebook/static/notebook/js/cell.js b/notebook/static/notebook/js/cell.js index 8594816e3..fc67293fd 100644 --- a/notebook/static/notebook/js/cell.js +++ b/notebook/static/notebook/js/cell.js @@ -1,4 +1,3 @@ -// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /** @@ -21,7 +20,7 @@ define([ "use strict"; var overlayHack = CodeMirror.scrollbarModel.native.prototype.overlayHack; - + CodeMirror.scrollbarModel.native.prototype.overlayHack = function () { overlayHack.apply(this, arguments); // Reverse `min-height: 18px` scrollbar hack on OS X @@ -55,6 +54,7 @@ define([ this.placeholder = config.placeholder || ''; this.selected = false; + this.anchor = false; this.rendered = false; this.mode = 'command'; @@ -154,30 +154,29 @@ define([ } }; + /** + * trigger on focus and on click to bubble up to the notebook and + * potentially extend the selection if shift-click, contract the selection + * if just codemirror focus (so edit mode). + * We **might** be able to move that to notebook `handle_edit_mode`. + */ + Cell.prototype._on_click = function(event){ + if (!this.selected) { + this.events.trigger('select.Cell', {'cell':this, 'extendSelection':event.shiftKey}); + } + } + /** * Subclasses can implement override bind_events. - * Be carefull to call the parent method when overwriting as it fires event. - * this will be triggerd after create_element in constructor. + * Be careful to call the parent method when overwriting as it fires event. + * this will be triggered after create_element in constructor. * @method bind_events */ Cell.prototype.bind_events = function () { var that = this; // We trigger events so that Cell doesn't have to depend on Notebook. that.element.click(function (event) { - if (!that.selected) { - that.events.trigger('select.Cell', {'cell':that}); - } - - // Cmdtrl-click should mark the cell. - var isMac = navigator.platform.slice(0, 3).toLowerCase() === 'mac'; - if ((!isMac && event.ctrlKey) || (isMac && event.metaKey)) { - that.marked = !that.marked; - } - }); - that.element.focusin(function (event) { - if (!that.selected) { - that.events.trigger('select.Cell', {'cell':that}); - } + that._on_click(event) }); if (this.code_mirror) { this.code_mirror.on("change", function(cm, change) { @@ -186,6 +185,9 @@ define([ } if (this.code_mirror) { this.code_mirror.on('focus', function(cm, change) { + if (!that.selected) { + that.events.trigger('select.Cell', {'cell':that}); + } that.events.trigger('edit_mode.Cell', {cell: that}); }); } @@ -239,7 +241,7 @@ define([ /** - * Triger typsetting of math by mathjax on current cell element + * Triger typesetting of math by mathjax on current cell element * @method typeset */ Cell.prototype.typeset = function () { @@ -251,7 +253,13 @@ define([ * @method select * @return is the action being taken */ - Cell.prototype.select = function () { + Cell.prototype.select = function (moveanchor) { + // if anchor is true, set the move the anchor + moveanchor = (moveanchor === undefined)? true:moveanchor; + if(moveanchor){ + this.anchor=true; + } + if (!this.selected) { this.element.addClass('selected'); this.element.removeClass('unselected'); @@ -265,10 +273,14 @@ define([ /** * handle cell level logic when the cell is unselected * @method unselect - * @param {bool} leave_selected - true to move cursor away and extend selection * @return is the action being taken */ - Cell.prototype.unselect = function (leave_selected) { + Cell.prototype.unselect = function (moveanchor) { + // if anchor is true, remove also the anchor + moveanchor = (moveanchor === undefined)? true:moveanchor; + if (moveanchor){ + this.anchor = false + } if (this.selected) { this.element.addClass('unselected'); this.element.removeClass('selected'); @@ -279,32 +291,9 @@ define([ } }; - /** - * Whether or not the cell is marked. - * @return {boolean} - */ - Object.defineProperty(Cell.prototype, 'marked', { - get: function() { - return this.element.hasClass('marked'); - }, - set: function(value) { - var isMarked = this.element.hasClass('marked'); - // Use a casting comparison. Allows for the caller to assign 0 or - // 1 instead of a boolean value, which in return means the caller - // can do cell.marked ^= true to toggle the mark. - if (isMarked != value) { - if (value) { - this.element.addClass('marked'); - } else { - this.element.removeClass('marked'); - } - this.events.trigger('marked_changed.Cell', {cell: this, value: value}); - } - } - }); /** - * should be overritten by subclass + * should be overwritten by subclass * @method execute */ Cell.prototype.execute = function () { @@ -426,6 +415,7 @@ define([ */ Cell.prototype.focus_cell = function () { this.element.focus(); + this._on_click({}); }; /** diff --git a/notebook/static/notebook/js/celltoolbar.js b/notebook/static/notebook/js/celltoolbar.js index 654bcc0e0..b0bb31a2b 100644 --- a/notebook/static/notebook/js/celltoolbar.js +++ b/notebook/static/notebook/js/celltoolbar.js @@ -354,7 +354,6 @@ define([ var chkb = $('').attr('type', 'checkbox'); var lbl = $('').append($('').text(name)); - lbl.append(chkb); chkb.attr("checked", getter(cell)); chkb.click(function(){ @@ -362,7 +361,7 @@ define([ setter(cell, !v); chkb.attr("checked", !v); }); - button_container.append($('').append(lbl)); + button_container.append($('').append(lbl).append(chkb)); }; }; @@ -387,13 +386,12 @@ define([ var text = $('').attr('type', 'text'); var lbl = $('').append($('').text(name)); - lbl.append(text); text.attr("value", getter(cell)); text.keyup(function(){ setter(cell, text.val()); }); - button_container.append($('').append(lbl)); + button_container.append($('').append(lbl).append(text)); IPython.keyboard_manager.register_events(text); }; }; diff --git a/notebook/static/notebook/js/codecell.js b/notebook/static/notebook/js/codecell.js index 59d8b4729..088249dc8 100644 --- a/notebook/static/notebook/js/codecell.js +++ b/notebook/static/notebook/js/codecell.js @@ -50,7 +50,15 @@ define([ */ CodeMirror.commands.delSpaceToPrevTabStop = function(cm){ var from = cm.getCursor(true), to = cm.getCursor(false), sel = !posEq(from, to); - if (!posEq(from, to)) { cm.replaceRange("", from, to); return; } + if (sel) { + var ranges = cm.listSelections(); + for (var i = ranges.length - 1; i >= 0; i--) { + var head = ranges[i].head; + var anchor = ranges[i].anchor; + cm.replaceRange("", Pos(head.line, head.ch), CodeMirror.Pos(anchor.line, anchor.ch)); + } + return; + } var cur = cm.getCursor(), line = cm.getLine(cur.line); var tabsize = cm.getOption('tabSize'); var chToPrevTabStop = cur.ch-(Math.ceil(cur.ch/tabsize)-1)*tabsize; @@ -188,7 +196,7 @@ define([ /** @method bind_events */ CodeCell.prototype.bind_events = function () { - Cell.prototype.bind_events.apply(this); + Cell.prototype.bind_events.apply(this, arguments); var that = this; this.element.focusout( @@ -393,7 +401,7 @@ define([ // Basic cell manipulation. CodeCell.prototype.select = function () { - var cont = Cell.prototype.select.apply(this); + var cont = Cell.prototype.select.apply(this, arguments); if (cont) { this.code_mirror.refresh(); this.auto_highlight(); @@ -402,7 +410,7 @@ define([ }; CodeCell.prototype.render = function () { - var cont = Cell.prototype.render.apply(this); + var cont = Cell.prototype.render.apply(this, arguments); // Always execute, even if we are already in the rendered state return cont; }; @@ -540,7 +548,7 @@ define([ * @return is the action being taken */ CodeCell.prototype.unselect = function() { - var cont = Cell.prototype.unselect.call(this); + var cont = Cell.prototype.unselect.apply(this, arguments); if (cont) { // When a code cell is unselected, make sure that the corresponding // tooltip and completer to that cell is closed. diff --git a/notebook/static/notebook/js/keyboardmanager.js b/notebook/static/notebook/js/keyboardmanager.js index 9955923df..21138b4ee 100644 --- a/notebook/static/notebook/js/keyboardmanager.js +++ b/notebook/static/notebook/js/keyboardmanager.js @@ -96,14 +96,14 @@ define([ 'i,i' : 'jupyter-notebook:interrupt-kernel', '0,0' : 'jupyter-notebook:confirm-restart-kernel', 'd,d' : 'jupyter-notebook:delete-cell', - 'esc': 'jupyter-notebook:close-pager-or-unmark-all-cells', + 'esc': 'jupyter-notebook:close-pager', 'up' : 'jupyter-notebook:select-previous-cell', 'k' : 'jupyter-notebook:select-previous-cell', 'j' : 'jupyter-notebook:select-next-cell', - 'shift-k': 'jupyter-notebook:extend-marked-cells-above', - 'shift-j': 'jupyter-notebook:extend-marked-cells-below', - 'shift-up': 'jupyter-notebook:extend-marked-cells-above', - 'shift-down': 'jupyter-notebook:extend-marked-cells-below', + 'shift-k': 'jupyter-notebook:extend-selection-above', + 'shift-j': 'jupyter-notebook:extend-selection-below', + 'shift-up': 'jupyter-notebook:extend-selection-above', + 'shift-down': 'jupyter-notebook:extend-selection-below', 'x' : 'jupyter-notebook:cut-cell', 'c' : 'jupyter-notebook:copy-cell', 'v' : 'jupyter-notebook:paste-cell-below', diff --git a/notebook/static/notebook/js/menubar.js b/notebook/static/notebook/js/menubar.js index 61207ff31..6ae6fc785 100644 --- a/notebook/static/notebook/js/menubar.js +++ b/notebook/static/notebook/js/menubar.js @@ -63,7 +63,7 @@ define([ // The selected cell loses focus when the menu is entered, so we // re-select it upon selection. var i = that.notebook.get_selected_index(); - that.notebook.select(i); + that.notebook.select(i, false); } ); }; diff --git a/notebook/static/notebook/js/notebook.js b/notebook/static/notebook/js/notebook.js index 54a889b61..14c8b42aa 100644 --- a/notebook/static/notebook/js/notebook.js +++ b/notebook/static/notebook/js/notebook.js @@ -29,6 +29,12 @@ define(function (require) { var scrollmanager = require('notebook/js/scrollmanager'); var commandpalette = require('notebook/js/commandpalette'); + var _SOFT_SELECTION_CLASS = 'jupyter-soft-selected'; + + function soft_selected(cell){ + return cell.element.hasClass(_SOFT_SELECTION_CLASS); + } + /** * Contains and manages cells. * @class Notebook @@ -58,6 +64,8 @@ define(function (require) { this.ws_url = options.ws_url; this._session_starting = false; this.last_modified = null; + // debug 484 + this._last_modified = 'init'; // Create default scroll manager. this.scroll_manager = new scrollmanager.ScrollManager(this); @@ -185,9 +193,6 @@ define(function (require) { Notebook.prototype.bind_events = function () { var that = this; - this.events.on('marked_changed.Cell', function() { - that.update_marked_status(); - }); this.events.on('set_next_input.Notebook', function (event, data) { if (data.replace) { @@ -219,7 +224,7 @@ define(function (require) { this.events.on('select.Cell', function (event, data) { var index = that.find_cell_index(data.cell); - that.select(index); + that.select(index, !data.extendSelection); }); this.events.on('edit_mode.Cell', function (event, data) { @@ -296,9 +301,6 @@ define(function (require) { expand_time(time); }); - this.scroll_manager.onScroll(function () { - that.update_marked_status(); - }, 100); // Firefox 22 broke $(window).on("beforeunload") // I'm not sure why or how. @@ -582,13 +584,30 @@ define(function (require) { return i; }; + + Notebook.prototype.get_selected_cells = function () { + return this.get_cells().filter(function(cell, index){ return cell.selected || soft_selected(cell) || cell.anchor}) + }; + + Notebook.prototype.get_selected_cells_indices = function () { + + var result = []; + this.get_cells().filter(function (cell, index) { + if (cell.selected || soft_selected(cell) || cell.anchor) { + result.push(index); + } + }); + return result; + }; + + /** * Get the currently selected cell. * * @return {Cell} The selected cell */ Notebook.prototype.get_selected_cell = function () { - var index = this.get_selected_index(); + var index = this.get_selected_cells_indices(); return this.get_cell(index); }; @@ -606,6 +625,15 @@ define(function (require) { } }; + Notebook.prototype.get_anchor_index = function () { + var result = null; + this.get_cell_elements().filter(function (index) { + if ($(this).data("cell").anchor === true) { + result = index; + } + }); + return result; + }; /** * Get the index of the currently selected cell. * @@ -620,144 +648,52 @@ define(function (require) { }); return result; }; - - /** - * Toggles the marks on the cells - * @param {Cell[]} [cells] - optionally specify what cells should be toggled - */ - Notebook.prototype.toggle_cells_marked = function(cells) { - cells = cells || this.get_cells(); - cells.forEach(function(cell) { cell.marked = !cell.marked; }); - }; - - /** - * Mark all of the cells - * @param {Cell[]} [cells] - optionally specify what cells should be marked - */ - Notebook.prototype.mark_all_cells = function(cells) { - cells = cells || this.get_cells(); - cells.forEach(function(cell) { cell.marked = true; }); - }; - - /** - * Unmark all of the cells - * @param {Cell[]} [cells] - optionally specify what cells should be unmarked - */ - Notebook.prototype.unmark_all_cells = function(cells) { - this.get_marked_cells(cells).forEach(function(cell) { cell.marked = false; }); - }; - - /** - * Set the cells that should be marked, exclusively - * @param {Cell[]} cells - */ - Notebook.prototype.set_marked_cells = function(cells) { - this.unmark_all_cells(); - this.mark_all_cells(cells); - }; - - /** - * Gets the cells that are marked - * @param {Cell[]} [cells] - optionally provide the cells to search through - * @return {Cell[]} marked cells - */ - Notebook.prototype.get_marked_cells = function(cells) { - cells = cells || this.get_cells(); - return cells.filter(function(cell) { return (cell.marked || cell.selected); }); - }; - - /** - * Sets the cells that are marked by indices - * @param {number[]} indices - * @param {Cell[]} [cells] - optionally provide the cells to search through - */ - Notebook.prototype.set_marked_indices = function(indices, cells) { - cells = cells || this.get_cells(); - this.unmark_all_cells(cells); - this.mark_all_cells(cells.filter(function(cell, index) { return indices.indexOf(index) !== -1; })); - }; - - /** - * Gets the indices of the cells that are marked - * @param {Cell[]} [cells] - optionally provide the cells to search through - * @return {number[]} marked cell indices - */ - Notebook.prototype.get_marked_indices = function(cells) { - cells = cells || this.get_cells(); - var markedCells = this.get_marked_cells(cells); - return markedCells.map(function(cell) { return cells.indexOf(cell); }); - }; - - /** - * Checks if the marked cells are contiguous - * @param {Cell[]} [cells] - optionally provide the cells to search through - * @return {boolean} - */ - Notebook.prototype.are_marked_cells_contiguous = function(cells) { - // Get a numerically sorted list of the marked indices. - var markedIndices = this.get_marked_indices(cells).sort( - function(a,b) { return a-b; }); - // Check for contiguousness - for (var i = 0; i < markedIndices.length - 1; i++) { - if (markedIndices[i+1] - markedIndices[i] !== 1) { - return false; - } - } - return true; - }; - - /** - * Checks if the marked cells specified by their indices are contiguous - * @param {number[]} indices - the cell indices to search through - * @param {Cell[]} [cells] - the cells to search through - * @return {boolean} - */ - Notebook.prototype.are_marked_indices_contiguous = function(indices, cells) { - cells = cells || this.get_cells(); - return this.are_marked_cells_contiguous(cells.filter(function(cell, index) { - return indices.indexOf(index) !== -1; - })); - }; - /** - * Extend the selected range - * - * @param {number} offset - */ - Notebook.prototype.extend_marked = function(offset) { - // Mark currently selected cell - this.get_selected_cell().marked = true; + // Cell selection. - // Select the cell in the offset direction. Bound index between 0 and - // the number of cells -1. - var selectedIndex = Math.min(Math.max(this.get_selected_index() + offset, 0), this.ncells()-1); - this.select(selectedIndex); - this.ensure_focused(); + Notebook.prototype.extend_selection_by = function(delta) { + var index = this.get_selected_index(); + // do not move anchor + return this.select(index+delta, false); }; - Notebook.prototype.update_marked_status = function() { - var marked_cells = this.get_marked_cells(); - var num_offscreen = 0; - var i; - for (i = 0; i < marked_cells.length; i++) { - if (!this.scroll_manager.is_cell_visible(marked_cells[i])) { - num_offscreen += 1; - } - } - this.events.trigger('marked_offscreen.Cell', num_offscreen); - }; + Notebook.prototype.update_soft_selection = function(){ + var i1 = this.get_selected_index(); + var i2 = this.get_anchor_index(); + var low = Math.min(i1, i2); + var high = Math.max(i1, i2); + if (low !== high){ + $('body').addClass('jupyter-multi-select'); + } else { + $('body').removeClass('jupyter-multi-select'); + } + this.get_cells().map(function(cell, index, all){ + if( low <= index && index <= high ){ + cell.element.addClass(_SOFT_SELECTION_CLASS); + } else { + cell.element.removeClass(_SOFT_SELECTION_CLASS); + } + }) + } - // Cell selection. + Notebook.prototype._contract_selection = function(){ + var i = this.get_selected_index(); + this.select(i, true); + } /** * Programmatically select a cell. * * @param {integer} index - A cell's index + * @param {bool} moveanchor – whether to move the selection + * anchor, default to true. * @return {Notebook} This notebook */ - Notebook.prototype.select = function (index) { + Notebook.prototype.select = function (index, moveanchor) { + moveanchor = (moveanchor===undefined)? true : moveanchor; + if (this.is_valid_cell_index(index)) { var sindex = this.get_selected_index(); if (sindex !== null && index !== sindex) { @@ -766,11 +702,13 @@ define(function (require) { if (this.mode !== 'command') { this.command_mode(); } - this.get_cell(sindex).unselect(); + this.get_cell(sindex).unselect(moveanchor); + } + if(moveanchor){ + this.get_cell(this.get_anchor_index()).unselect(true); } var cell = this.get_cell(index); - cell.select(); - this.update_marked_status(); + cell.select(moveanchor); if (cell.cell_type === 'heading') { this.events.trigger('selected_cell_type_changed.Notebook', {'cell_type':cell.cell_type,level:cell.level} @@ -781,17 +719,20 @@ define(function (require) { ); } } + this.update_soft_selection(); return this; }; /** * Programmatically select the next cell. * + * @param {bool} moveanchor – whether to move the selection + * anchor, default to true. * @return {Notebook} This notebook */ - Notebook.prototype.select_next = function () { + Notebook.prototype.select_next = function (moveanchor) { var index = this.get_selected_index(); - this.select(index+1); + this.select(index+1, moveanchor); return this; }; @@ -800,9 +741,9 @@ define(function (require) { * * @return {Notebook} This notebook */ - Notebook.prototype.select_prev = function () { + Notebook.prototype.select_prev = function (moveanchor) { var index = this.get_selected_index(); - this.select(index-1); + this.select(index-1, moveanchor); return this; }; @@ -856,6 +797,7 @@ define(function (require) { * @param {Cell} [cell] Cell to enter edit mode on. */ Notebook.prototype.handle_edit_mode = function (cell) { + this._contract_selection(); if (cell && this.mode !== 'edit') { cell.edit_mode(); this.mode = 'edit'; @@ -868,6 +810,7 @@ define(function (require) { * Make a cell enter edit mode. */ Notebook.prototype.edit_mode = function () { + this._contract_selection(); var cell = this.get_selected_cell(); if (cell && this.mode !== 'edit') { cell.unrender(); @@ -976,7 +919,7 @@ define(function (require) { */ Notebook.prototype.delete_cells = function(indices) { if (indices === undefined) { - indices = this.get_marked_indices(); + indices = this.get_selected_cells_indices(); } this.undelete_backup = []; @@ -1416,7 +1359,7 @@ define(function (require) { * Copy cells. */ Notebook.prototype.copy_cell = function () { - var cells = this.get_marked_cells(); + var cells = this.get_selected_cells(); if (cells.length === 0) { cells = [this.get_selected_cell()]; } @@ -1505,7 +1448,6 @@ define(function (require) { // Unrender the new cell so we can call set_text. new_cell.unrender(); new_cell.set_text(texta); - new_cell.marked = cell.marked; } }; @@ -1562,14 +1504,13 @@ define(function (require) { this.delete_cells(indices); this.select(this.find_cell_index(target)); - this.unmark_all_cells(); }; /** * Merge the selected range of cells */ - Notebook.prototype.merge_marked_cells = function() { - this.merge_cells(this.get_marked_indices()); + Notebook.prototype.merge_selected_cells = function() { + this.merge_cells(this.get_selected_cells_indices()); }; /** @@ -2007,27 +1948,27 @@ define(function (require) { /** * Execute or render cell outputs and go into command mode. */ - Notebook.prototype.execute_marked_cells = function () { - this.execute_cells(this.get_marked_indices()); + Notebook.prototype.execute_selected_cells = function () { + this.execute_cells(this.get_selected_cells_indices()); }; + /** - * Alias for execute_marked_cells, for backwards compatibility -- + * Alias for execute_selected_cells, for backwards compatibility -- * previously, doing "Run Cell" would only ever run a single cell (hence * `execute_cell`), but now it runs all marked cells, so that's the * preferable function to use. But it is good to keep this function to avoid * breaking existing extensions, etc. */ Notebook.prototype.execute_cell = function () { - this.execute_marked_cells(); + this.execute_selected_cells(); }; /** * Execute or render cell outputs and insert a new cell below. */ Notebook.prototype.execute_cell_and_insert_below = function () { - // execute the marked cells, and don't insert anything - var indices = this.get_marked_indices(); + var indices = this.get_selected_cells_indices(); if (indices.length > 1) { this.execute_cells(indices); return; @@ -2059,8 +2000,7 @@ define(function (require) { * Execute or render cell outputs and select the next cell. */ Notebook.prototype.execute_cell_and_select_below = function () { - // execute the marked cells, and don't select anything - var indices = this.get_marked_indices(); + var indices = this.get_selected_cells_indices(); if (indices.length > 1) { this.execute_cells(indices); return; @@ -2320,6 +2260,8 @@ define(function (require) { function (data) { var last_modified = new Date(data.last_modified); if (last_modified > that.last_modified) { + console.warn("Last saving was done on `"+that.last_modified+"`("+that._last_modified+"), "+ + "while the current file seem to have been saved on `"+data.last_modified+"`") dialog.modal({ notebook: that, keyboard_manager: that.keyboard_manager, @@ -2365,6 +2307,8 @@ define(function (require) { Notebook.prototype.save_notebook_success = function (start, data) { this.set_dirty(false); this.last_modified = new Date(data.last_modified); + // debug 484 + this._last_modified = 'save-success:'+data.last_modified; if (data.message) { // save succeeded, but validation failed. var body = $("