zeppelin-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From jongy...@apache.org
Subject zeppelin git commit: [Zeppelin-3307] - Improved shared browsing/editing for the note
Date Thu, 07 Jun 2018 05:49:06 GMT
Repository: zeppelin
Updated Branches:
  refs/heads/master b8c6b5d57 -> c972e257f


[Zeppelin-3307] - Improved shared browsing/editing for the note

### What is this PR for?
Now if the note is opened in several tabs (or several users are watching or editing it), then
there may be problems. Loss of code entered by the user, reset the cursor position.
This PR adds a basic opportunity for collaborative editing. For the organization of joint
editing, the library diff-match-patch is used.
PR does not change the logic of operation if the note is used by one person.
Also, maybe this will solve the problem with [ZEPPELIN-3131](https://issues.apache.org/jira/browse/ZEPPELIN-3131).

### What type of PR is it?
Improvement

### What is the Jira issue?
[ZEPPELIN-3307](https://issues.apache.org/jira/browse/ZEPPELIN-3307)

### Screenshots (if appropriate)

![gif](https://user-images.githubusercontent.com/30798933/37095049-e6c64e32-2225-11e8-96c8-517ac745a254.gif)

### Questions:
* Does the licenses files need update? no
* Is there breaking changes for older versions? no
* Does this needs documentation? no

Author: Savalek <def113@mail.ru>

Closes #2848 from Savalek/ZEPPELIN-3307 and squashes the following commits:

2347eb53f [Savalek] Merge remote-tracking branch 'apache/master' into ZEPPELIN-3307
3688d4b85 [Savalek] [ZEPPELIN-3307] added description in documentation. add tests.
566f844d1 [Savalek] [ZEPPELIN-3307] add tests for collaborative mode
88a964048 [Savalek] [ZEPPELIN-3307] checkstyle fix
32beb3de3 [Savalek] [ZEPPELIN-3307] add collaborative mode enable/disable option to zeppelin-site.xml
47e7db47c [Savalek] [ZEPPELIN-3307] small refactoring
8fad55a93 [Savalek] [ZEPPELIN-3307] small refactoring
b4c5b20ad [Savalek] [ZEPPELIN-3307] add collaborative users list to tooltip. refactoring.
b131246c4 [Savalek] [ZEPPELIN-3307] - refactoring
d8e6cde9f [Savalek] [ZEPPELIN-3307] resolve merge conflicts
fbaa809ef [Savalek] Merge remote-tracking branch 'apache/master' into ZEPPELIN-3307
8651f7634 [Savalek] delete debug
a1700d58b [Savalek] codestyle fix
d7f449c7d [Savalek] Merge branch 'master' into ZEPPELIN-3307
4463ff026 [Savalek] coop icon add
f87ab507b [Savalek] coop_raw_1


Project: http://git-wip-us.apache.org/repos/asf/zeppelin/repo
Commit: http://git-wip-us.apache.org/repos/asf/zeppelin/commit/c972e257
Tree: http://git-wip-us.apache.org/repos/asf/zeppelin/tree/c972e257
Diff: http://git-wip-us.apache.org/repos/asf/zeppelin/diff/c972e257

Branch: refs/heads/master
Commit: c972e257f1f4dfbab1f682f3da8dba6c500dfbb8
Parents: b8c6b5d
Author: Savalek <def113@mail.ru>
Authored: Fri Jun 1 15:58:27 2018 +0300
Committer: Jongyoul Lee <jongyoul@apache.org>
Committed: Thu Jun 7 14:49:00 2018 +0900

----------------------------------------------------------------------
 conf/zeppelin-site.xml.template                 |  6 ++
 docs/setup/operation/configuration.md           |  6 ++
 .../zeppelin/conf/ZeppelinConfiguration.java    |  6 ++
 zeppelin-server/pom.xml                         | 20 +++--
 .../apache/zeppelin/socket/NotebookServer.java  | 90 ++++++++++++++++++++
 .../zeppelin/socket/NotebookServerTest.java     | 73 +++++++++++++++-
 zeppelin-web/e2e/collaborativeMode.spec.js      | 71 +++++++++++++++
 zeppelin-web/e2e/home.spec.js                   |  2 +-
 zeppelin-web/package.json                       |  3 +-
 .../src/app/notebook/notebook-actionBar.html    | 10 +++
 .../src/app/notebook/notebook.controller.js     | 12 +++
 zeppelin-web/src/app/notebook/notebook.css      |  6 ++
 .../notebook/paragraph/paragraph.controller.js  | 36 +++++++-
 .../websocket/websocket-event.factory.js        |  4 +
 .../websocket/websocket-message.service.js      | 14 +++
 .../zeppelin/notebook/socket/Message.java       | 14 ++-
 16 files changed, 361 insertions(+), 12 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/conf/zeppelin-site.xml.template
----------------------------------------------------------------------
diff --git a/conf/zeppelin-site.xml.template b/conf/zeppelin-site.xml.template
index e665a9b..098fed5 100755
--- a/conf/zeppelin-site.xml.template
+++ b/conf/zeppelin-site.xml.template
@@ -67,6 +67,12 @@
   <description>hide homescreen notebook from list when this value set to true</description>
 </property>
 
+<property>
+  <name>zeppelin.notebook.collaborative.mode.enable</name>
+  <value>true</value>
+  <description>Enable collaborative mode</description>
+</property>
+
 <!-- Google Cloud Storage notebook storage -->
 <!--
 <property>

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/docs/setup/operation/configuration.md
----------------------------------------------------------------------
diff --git a/docs/setup/operation/configuration.md b/docs/setup/operation/configuration.md
index ed4e1f2..7392f57 100644
--- a/docs/setup/operation/configuration.md
+++ b/docs/setup/operation/configuration.md
@@ -102,6 +102,12 @@ If both are defined, then the **environment variables** will take priority.
     <td>Context path of the web application</td>
   </tr>
   <tr>
+    <td><h6 class="properties">ZEPPELIN_NOTEBOOK_COLLABORATIVE_MODE_ENABLE</h6></td>
+    <td><h6 class="properties">zeppelin.notebook.collaborative.mode.enable</h6></td>
+    <td>true</td>
+    <td>Enable basic opportunity for collaborative editing. Does not change the logic
of operation if the note is used by one person.</td>
+  </tr>
+  <tr>
     <td><h6 class="properties">ZEPPELIN_SSL</h6></td>
     <td><h6 class="properties">zeppelin.ssl</h6></td>
     <td>false</td>

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
----------------------------------------------------------------------
diff --git a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
index 1aedb7f..83d8e23 100644
--- a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
+++ b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
@@ -607,6 +607,10 @@ public class ZeppelinConfiguration extends XMLConfiguration {
     return getString(ConfVars.ZEPPELIN_NOTEBOOK_CRON_FOLDERS);
   }
 
+  public Boolean isZeppelinNotebookCollaborativeModeEnable() {
+    return getBoolean(ConfVars.ZEPPELIN_NOTEBOOK_COLLABORATIVE_MODE_ENABLE);
+  }
+
   public String getZeppelinProxyUrl() {
     return getString(ConfVars.ZEPPELIN_PROXY_URL);
   }
@@ -813,6 +817,8 @@ public class ZeppelinConfiguration extends XMLConfiguration {
     ZEPPELIN_NOTEBOOK_GIT_REMOTE_USERNAME("zeppelin.notebook.git.remote.username", "token"),
     ZEPPELIN_NOTEBOOK_GIT_REMOTE_ACCESS_TOKEN("zeppelin.notebook.git.remote.access-token",
""),
     ZEPPELIN_NOTEBOOK_GIT_REMOTE_ORIGIN("zeppelin.notebook.git.remote.origin", "origin"),
+    ZEPPELIN_NOTEBOOK_COLLABORATIVE_MODE_ENABLE("zeppelin.notebook.collaborative.mode.enable",
+            true),
     ZEPPELIN_NOTEBOOK_CRON_ENABLE("zeppelin.notebook.cron.enable", false),
     ZEPPELIN_NOTEBOOK_CRON_FOLDERS("zeppelin.notebook.cron.folders", null),
     ZEPPELIN_PROXY_URL("zeppelin.proxy.url", null),

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-server/pom.xml
----------------------------------------------------------------------
diff --git a/zeppelin-server/pom.xml b/zeppelin-server/pom.xml
index f91f6da..9bc2a4c 100644
--- a/zeppelin-server/pom.xml
+++ b/zeppelin-server/pom.xml
@@ -367,13 +367,19 @@
         </exclusions>
       </dependency>
 
-    <dependency>
-      <groupId>org.apache.zeppelin</groupId>
-      <artifactId>zeppelin-zengine</artifactId>
-      <version>${project.version}</version>
-      <classifier>tests</classifier>
-      <scope>test</scope>
-    </dependency>
+      <dependency>
+        <groupId>org.apache.zeppelin</groupId>
+        <artifactId>zeppelin-zengine</artifactId>
+        <version>${project.version}</version>
+        <classifier>tests</classifier>
+        <scope>test</scope>
+      </dependency>
+
+      <dependency>
+        <groupId>org.bitbucket.cowwoc</groupId>
+        <artifactId>diff-match-patch</artifactId>
+        <version>1.1</version>
+      </dependency>
 
   </dependencies>
   <build>

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
----------------------------------------------------------------------
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
index 72fc63f..ca0b0ab 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
@@ -98,6 +98,8 @@ import org.apache.zeppelin.user.AuthenticationInfo;
 import org.apache.zeppelin.util.WatcherSecurityKey;
 import org.apache.zeppelin.utils.InterpreterBindingUtils;
 import org.apache.zeppelin.utils.SecurityUtils;
+import org.bitbucket.cowwoc.diffmatchpatch.DiffMatchPatch;
+
 
 /**
  * Zeppelin websocket service.
@@ -123,6 +125,10 @@ public class NotebookServer extends WebSocketServlet
   }
 
 
+  private HashSet<String> collaborativeModeList = new HashSet<>();
+  private Boolean collaborativeModeEnable = ZeppelinConfiguration
+          .create()
+          .isZeppelinNotebookCollaborativeModeEnable();
   private static final Logger LOG = LoggerFactory.getLogger(NotebookServer.class);
   private static Gson gson = new GsonBuilder()
       .setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
@@ -377,6 +383,9 @@ public class NotebookServer extends WebSocketServlet
         case REMOVE_NOTE_FORMS:
           removeNoteForms(conn, userAndRoles, notebook, messagereceived);
           break;
+        case PATCH_PARAGRAPH:
+          patchParagraph(conn, userAndRoles, notebook, messagereceived);
+          break;
         default:
           break;
       }
@@ -433,6 +442,7 @@ public class NotebookServer extends WebSocketServlet
       if (!socketList.contains(socket)) {
         socketList.add(socket);
       }
+      checkCollaborativeStatus(noteId, socketList);
     }
   }
 
@@ -442,6 +452,7 @@ public class NotebookServer extends WebSocketServlet
       if (socketList != null) {
         socketList.remove(socket);
       }
+      checkCollaborativeStatus(noteId, socketList);
     }
   }
 
@@ -460,6 +471,29 @@ public class NotebookServer extends WebSocketServlet
     }
   }
 
+  private void checkCollaborativeStatus(String noteId, List<NotebookSocket> socketList)
{
+    if (!collaborativeModeEnable) {
+      return;
+    }
+    boolean collaborativeStatusNew = socketList.size() > 1;
+    if (collaborativeStatusNew) {
+      collaborativeModeList.add(noteId);
+    } else {
+      collaborativeModeList.remove(noteId);
+    }
+
+    Message message = new Message(OP.COLLABORATIVE_MODE_STATUS);
+    message.put("status", collaborativeStatusNew);
+    if (collaborativeStatusNew) {
+      HashSet<String> userList = new HashSet<>();
+      for (NotebookSocket noteSocket: socketList) {
+        userList.add(noteSocket.getUser());
+      }
+      message.put("users", userList);
+    }
+    broadcast(noteId, message);
+  }
+
   private String getOpenNoteId(NotebookSocket socket) {
     String id = null;
     synchronized (noteSocketMap) {
@@ -1284,6 +1318,62 @@ public class NotebookServer extends WebSocketServlet
     }
   }
 
+  private void patchParagraph(NotebookSocket conn, HashSet<String> userAndRoles,
+                              Notebook notebook, Message fromMessage) throws IOException
{
+    if (!collaborativeModeEnable) {
+      return;
+    }
+    String paragraphId = fromMessage.getType("id", LOG);
+    if (paragraphId == null) {
+      return;
+    }
+
+    String noteId = getOpenNoteId(conn);
+    if (noteId == null) {
+      noteId = fromMessage.getType("noteId", LOG);
+      if (noteId == null) {
+        return;
+      }
+    }
+
+    if (!hasParagraphWriterPermission(conn, notebook, noteId,
+        userAndRoles, fromMessage.principal, "write")) {
+      return;
+    }
+
+    final Note note = notebook.getNote(noteId);
+    if (note == null) {
+      return;
+    }
+    Paragraph p = note.getParagraph(paragraphId);
+    if (p == null) {
+      return;
+    }
+
+    DiffMatchPatch dmp = new DiffMatchPatch();
+    String patchText = fromMessage.getType("patch", LOG);
+    if (patchText == null) {
+      return;
+    }
+
+    LinkedList<DiffMatchPatch.Patch> patches = null;
+    try {
+      patches = (LinkedList<DiffMatchPatch.Patch>) dmp.patchFromText(patchText);
+    } catch (ClassCastException e) {
+      LOG.error("Failed to parse patches", e);
+    }
+    if (patches == null) {
+      return;
+    }
+
+    String paragraphText = p.getText() == null ? "" : p.getText();
+    paragraphText = (String) dmp.patchApply(patches, paragraphText)[0];
+    p.setText(paragraphText);
+    Message message = new Message(OP.PATCH_PARAGRAPH).put("patch", patchText)
+                                                     .put("paragraphId", p.getId());
+    broadcastExcept(note.getId(), message, conn);
+  }
+
   private void cloneNote(NotebookSocket conn, HashSet<String> userAndRoles, Notebook
notebook,
           Message fromMessage) throws IOException {
     String noteId = getOpenNoteId(conn);

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
index 68c206b..c23fce7 100644
--- a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
+++ b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
@@ -47,6 +47,7 @@ import java.util.List;
 
 import javax.servlet.http.HttpServletRequest;
 
+import org.apache.zeppelin.conf.ZeppelinConfiguration;
 import org.apache.zeppelin.display.AngularObject;
 import org.apache.zeppelin.display.AngularObjectBuilder;
 import org.apache.zeppelin.display.AngularObjectRegistry;
@@ -108,6 +109,72 @@ public class NotebookServerTest extends AbstractTestRestApi {
   }
 
   @Test
+  public void testCollaborativeEditing() throws IOException {
+    if (!ZeppelinConfiguration.create().isZeppelinNotebookCollaborativeModeEnable()) {
+      return;
+    }
+    NotebookSocket sock1 = createWebSocket();
+    NotebookSocket sock2 = createWebSocket();
+
+    String noteName = "Note with millis " + System.currentTimeMillis();
+    notebookServer.onMessage(sock1, new Message(OP.NEW_NOTE).put("name", noteName).toJson());
+    Note createdNote = null;
+    for (Note note : notebook.getAllNotes()) {
+      if (note.getName().equals(noteName)) {
+        createdNote = note;
+        break;
+      }
+    }
+
+    Message message = new Message(OP.GET_NOTE).put("id", createdNote.getId());
+    notebookServer.onMessage(sock1, message.toJson());
+    notebookServer.onMessage(sock2, message.toJson());
+
+    Paragraph paragraph = createdNote.getParagraphs().get(0);
+    String paragraphId = paragraph.getId();
+
+    String[] patches = new String[]{
+        "@@ -0,0 +1,3 @@\n+ABC\n",            // ABC
+        "@@ -1,3 +1,4 @@\n ABC\n+%0A\n",      // press Enter
+        "@@ -1,4 +1,7 @@\n ABC%0A\n+abc\n",   // abc
+        "@@ -1,7 +1,45 @@\n ABC\n-%0Aabc\n+ ssss%0Aabc ssss\n" // add text in two string
+    };
+
+    int sock1SendCount = 0;
+    int sock2SendCount = 0;
+    reset(sock1);
+    reset(sock2);
+    patchParagraph(sock1, paragraphId, patches[0]);
+    assertEquals("ABC", paragraph.getText());
+    verify(sock1, times(sock1SendCount)).send(anyString());
+    verify(sock2, times(++sock2SendCount)).send(anyString());
+
+    patchParagraph(sock2, paragraphId, patches[1]);
+    assertEquals("ABC\n", paragraph.getText());
+    verify(sock1, times(++sock1SendCount)).send(anyString());
+    verify(sock2, times(sock2SendCount)).send(anyString());
+
+    patchParagraph(sock1, paragraphId, patches[2]);
+    assertEquals("ABC\nabc", paragraph.getText());
+    verify(sock1, times(sock1SendCount)).send(anyString());
+    verify(sock2, times(++sock2SendCount)).send(anyString());
+
+    patchParagraph(sock2, paragraphId, patches[3]);
+    assertEquals("ABC ssss\nabc ssss", paragraph.getText());
+    verify(sock1, times(++sock1SendCount)).send(anyString());
+    verify(sock2, times(sock2SendCount)).send(anyString());
+
+    notebook.removeNote(createdNote.getId(), anonymous);
+  }
+
+  private void patchParagraph(NotebookSocket noteSocket, String paragraphId, String patch)
{
+    Message message = new Message(OP.PATCH_PARAGRAPH);
+    message.put("patch", patch);
+    message.put("id", paragraphId);
+    notebookServer.onMessage(noteSocket, message.toJson());
+  }
+
+  @Test
   public void testMakeSureNoAngularObjectBroadcastToWebsocketWhoFireTheEvent()
           throws IOException, InterruptedException {
     // create a notebook
@@ -414,8 +481,12 @@ public class NotebookServerTest extends AbstractTestRestApi {
         .put("name", noteName)
         .put("defaultInterpreterId", defaultInterpreterId).toJson());
 
+    int sendCount = 2;
+    if (ZeppelinConfiguration.create().isZeppelinNotebookCollaborativeModeEnable()) {
+      sendCount++;
+    }
     // expect the events are broadcasted properly
-    verify(sock1, times(2)).send(anyString());
+    verify(sock1, times(sendCount)).send(anyString());
 
     Note createdNote = null;
     for (Note note : notebook.getAllNotes()) {

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/e2e/collaborativeMode.spec.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/e2e/collaborativeMode.spec.js b/zeppelin-web/e2e/collaborativeMode.spec.js
new file mode 100644
index 0000000..6880090
--- /dev/null
+++ b/zeppelin-web/e2e/collaborativeMode.spec.js
@@ -0,0 +1,71 @@
+describe('Collaborative mode tests', function () {
+
+  let clickOn = function(elem) {
+    browser.actions().mouseMove(elem).click().perform()
+  };
+
+  let waitVisibility = function(elem) {
+    browser.wait(protractor.ExpectedConditions.visibilityOf(elem))
+  };
+
+  let test_text_1 = "_one_more_text_for_tests";      // without space!!!
+  let test_text_2 = "Collaborative_mode_test_text";  // without space!!!
+
+  browser.get('http://localhost:8080');
+  clickOn(element(by.linkText('Create new note')));
+  waitVisibility(element(by.id('noteCreateModal')));
+  clickOn(element(by.id('createNoteButton')));
+  let user1Browser = browser.forkNewDriverInstance();
+  let user2Browser = browser.forkNewDriverInstance();
+  browser.getCurrentUrl().then(function (url) {
+    user1Browser.get(url);
+    user2Browser.get(url);
+  });
+  waitVisibility(element(by.xpath('//*[@uib-tooltip="Users who watch this note: anonymous"]')));
+  browser.sleep(500);
+
+  it('user 1 received the first patch', function () {
+    browser.switchTo().activeElement().sendKeys(test_text_1);
+    browser.sleep(500);
+    user1Browser.isElementPresent(by.xpath('//span[contains(text(), \'' + test_text_1 + '\')]'))
+      .then(function (isPresent) {
+      expect(isPresent).toBe(true);
+    });
+  });
+
+  it('user 2 received the first patch', function () {
+    user2Browser.isElementPresent(by.xpath('//span[contains(text(), \'' + test_text_1 + '\')]'))
+      .then(function (isPresent) {
+      expect(isPresent).toBe(true);
+    });
+  });
+
+  it('user root received a first patch', function () {
+    user1Browser.switchTo().activeElement().sendKeys(test_text_2);
+    user1Browser.sleep(500);
+    browser.isElementPresent(by.xpath('//span[contains(text(), \'' + test_text_2 +
+      test_text_1 + '\')]')).then(function (isPresent) {
+      expect(isPresent).toBe(true);
+    });
+  });
+
+  it('user 2 received the second patch', function () {
+    user2Browser.isElementPresent(by.xpath('//span[contains(text(), \'' + test_text_2 +
+      test_text_1 + '\')]')).then(function (isPresent) {
+      expect(isPresent).toBe(true);
+    });
+  });
+
+  it('finish', function () {
+    user1Browser.close();
+    user2Browser.close();
+    clickOn(element(by.xpath('//*[@id="main"]//button[@ng-click="moveNoteToTrash(note.id)"]')));
+    let moveToTrashDialogPath =
+      '//div[@class="modal-dialog"][contains(.,"This note will be moved to trash")]';
+    waitVisibility(element(by.xpath(moveToTrashDialogPath)));
+    let okButton = element(
+      by.xpath(moveToTrashDialogPath + '//div[@class="modal-footer"]//button[contains(.,"OK")]'));
+    clickOn(okButton);
+  });
+
+});

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/e2e/home.spec.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/e2e/home.spec.js b/zeppelin-web/e2e/home.spec.js
index 299fbb5..5159a32 100644
--- a/zeppelin-web/e2e/home.spec.js
+++ b/zeppelin-web/e2e/home.spec.js
@@ -16,7 +16,7 @@ describe('Home e2e Test', function() {
 
   let scrollToElementAndClick = function(elem) {
     browser.executeScript("arguments[0].scrollIntoView(false);", elem.getWebElement())
-    browser.sleep(100)
+    browser.sleep(300)
     clickOn(elem)
   }
 

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/package.json
----------------------------------------------------------------------
diff --git a/zeppelin-web/package.json b/zeppelin-web/package.json
index d89badf..c46f32b 100644
--- a/zeppelin-web/package.json
+++ b/zeppelin-web/package.json
@@ -34,7 +34,8 @@
     "headroom.js": "^0.9.3",
     "moment": "^2.18.1",
     "moment-duration-format": "^1.3.0",
-    "scrollmonitor": "^1.2.3"
+    "scrollmonitor": "^1.2.3",
+    "diff-match-patch": "1.0.0"
   },
   "devDependencies": {
     "autoprefixer": "^6.5.4",

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/src/app/notebook/notebook-actionBar.html
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/notebook/notebook-actionBar.html b/zeppelin-web/src/app/notebook/notebook-actionBar.html
index 2229223..c891ad0 100644
--- a/zeppelin-web/src/app/notebook/notebook-actionBar.html
+++ b/zeppelin-web/src/app/notebook/notebook-actionBar.html
@@ -249,6 +249,16 @@ limitations under the License.
         </button>
       </span>
 
+      <span class="labelBtn" style="vertical-align:middle; display:inline-block;">
+        <button type="button"
+                class="btn btn-default btn-xs"
+                ng-show="collaborativeMode"
+                tooltip-placement="bottom" uib-tooltip="Users who watch this note: {{collaborativeModeUsers.join(',
')}}"
+                style="background-color: rgba(0,151,255,0.36)">
+          <i class="icon-eye"> {{collaborativeModeUsers.length}}</i>
+        </button>
+      </span>
+
       <span ng-hide="viewOnly">
       <div class="labelBtn btn-group" ng-if="note.config.isZeppelinNotebookCronEnable">
         <div class="btn btn-default btn-xs dropdown-toggle"

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/src/app/notebook/notebook.controller.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/notebook/notebook.controller.js b/zeppelin-web/src/app/notebook/notebook.controller.js
index 5135e1b..5871908 100644
--- a/zeppelin-web/src/app/notebook/notebook.controller.js
+++ b/zeppelin-web/src/app/notebook/notebook.controller.js
@@ -35,6 +35,8 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope,
   $scope.viewOnly = false;
   $scope.showSetting = false;
   $scope.showRevisionsComparator = false;
+  $scope.collaborativeMode = false;
+  $scope.collaborativeModeUsers = [];
   $scope.looknfeelOption = ['default', 'simple', 'report'];
   $scope.noteFormTitle = null;
   $scope.cronOption = [
@@ -1257,6 +1259,16 @@ function NotebookCtrl($scope, $route, $routeParams, $location, $rootScope,
     $scope.saveCursorPosition(paragraph);
   });
 
+  $scope.$on('collaborativeModeStatus', function(event, data) {
+    $scope.collaborativeMode = Boolean(data.status);
+    $scope.collaborativeModeUsers = data.users;
+  });
+
+  $scope.$on('patchReceived', function(event, data) {
+    $scope.collaborativeMode = true;
+  });
+
+
   $scope.$on('runAllBelowAndCurrent', function(event, paragraph, isNeedConfirm) {
     let allParagraphs = $scope.note.paragraphs;
     let toRunParagraphs = [];

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/src/app/notebook/notebook.css
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/notebook/notebook.css b/zeppelin-web/src/app/notebook/notebook.css
index 47a7b86..4a85cc0 100644
--- a/zeppelin-web/src/app/notebook/notebook.css
+++ b/zeppelin-web/src/app/notebook/notebook.css
@@ -473,3 +473,9 @@
 .notebook-form-title {
   padding: 3px;
 }
+
+/*Bootstrap uib-tooltip: max-width = 200px
+  because of this the string can come out */
+.tooltip-inner {
+  max-width: none !important;
+}

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
index 41474be..cbe8877 100644
--- a/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
+++ b/zeppelin-web/src/app/notebook/paragraph/paragraph.controller.js
@@ -16,6 +16,7 @@ import {SpellResult} from '../../spell';
 import {isParagraphRunning, ParagraphStatus} from './paragraph.status';
 
 import moment from 'moment';
+import DiffMatchPatch from 'diff-match-patch';
 
 require('moment-duration-format');
 
@@ -42,6 +43,8 @@ function ParagraphCtrl($scope, $rootScope, $route, $window, $routeParams,
$locat
   $scope.paragraph.results.msg = [];
   $scope.originalText = '';
   $scope.editor = null;
+  $scope.cursorPosition = null;
+  $scope.diffMatchPatch = new DiffMatchPatch();
 
   // transactional info for spell execution
   $scope.spellTransaction = {
@@ -702,9 +705,24 @@ function ParagraphCtrl($scope, $rootScope, $route, $window, $routeParams,
$locat
     let dirtyText = session.getValue();
     $scope.dirtyText = dirtyText;
     if ($scope.dirtyText !== $scope.originalText) {
-      $scope.startSaveTimer();
+      if ($scope.collaborativeMode) {
+        $scope.sendPatch();
+      } else {
+        $scope.startSaveTimer();
+      }
     }
     setParagraphMode(session, dirtyText, editor.getCursorPosition());
+    if ($scope.cursorPosition) {
+      editor.moveCursorToPosition($scope.cursorPosition);
+      $scope.cursorPosition = null;
+    }
+  };
+
+  $scope.sendPatch = function() {
+    $scope.originalText = $scope.originalText ? $scope.originalText : '';
+    let patch = $scope.diffMatchPatch.patch_make($scope.originalText, $scope.dirtyText).toString();
+    $scope.originalText = $scope.dirtyText;
+    return websocketMsgSrv.patchParagraph($scope.paragraph.id, $route.current.pathParams.noteId,
patch);
   };
 
   $scope.aceLoaded = function(_editor) {
@@ -1562,6 +1580,22 @@ function ParagraphCtrl($scope, $rootScope, $route, $window, $routeParams,
$locat
     $scope.updateParagraph(oldPara, newPara, updateCallback);
   });
 
+  $scope.$on('patchReceived', function(event, data) {
+    if (data.paragraphId === $scope.paragraph.id) {
+      let patch = data.patch;
+      patch = $scope.diffMatchPatch.patch_fromText(patch);
+      if (!$scope.paragraph.text || $scope.paragraph.text === undefined) {
+        $scope.paragraph.text = '';
+      }
+      $scope.paragraph.text = $scope.diffMatchPatch.patch_apply(patch, $scope.paragraph.text)[0];
+      $scope.originalText = angular.copy($scope.paragraph.text);
+      let newPosition = $scope.editor.getCursorPosition();
+      if (newPosition && newPosition.row && newPosition.column) {
+        $scope.cursorPosition = $scope.editor.getCursorPosition();
+      }
+    }
+  });
+
   $scope.$on('updateProgress', function(event, data) {
     if (data.id === $scope.paragraph.id) {
       $scope.currentProgress = data.progress;

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/src/components/websocket/websocket-event.factory.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/components/websocket/websocket-event.factory.js b/zeppelin-web/src/components/websocket/websocket-event.factory.js
index 80b8807..91d7076 100644
--- a/zeppelin-web/src/components/websocket/websocket-event.factory.js
+++ b/zeppelin-web/src/components/websocket/websocket-event.factory.js
@@ -110,6 +110,10 @@ function WebsocketEventFactory($rootScope, $websocket, $location, baseUrlSrv,
ng
       });
     } else if (op === 'PARAGRAPH') {
       $rootScope.$broadcast('updateParagraph', data);
+    } else if (op === 'PATCH_PARAGRAPH') {
+      $rootScope.$broadcast('patchReceived', data);
+    } else if (op === 'COLLABORATIVE_MODE_STATUS') {
+      $rootScope.$broadcast('collaborativeModeStatus', data);
     } else if (op === 'RUN_PARAGRAPH_USING_SPELL') {
       $rootScope.$broadcast('runParagraphUsingSpell', data);
     } else if (op === 'PARAGRAPH_APPEND_OUTPUT') {

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-web/src/components/websocket/websocket-message.service.js
----------------------------------------------------------------------
diff --git a/zeppelin-web/src/components/websocket/websocket-message.service.js b/zeppelin-web/src/components/websocket/websocket-message.service.js
index f0cf92b..e60b4e7 100644
--- a/zeppelin-web/src/components/websocket/websocket-message.service.js
+++ b/zeppelin-web/src/components/websocket/websocket-message.service.js
@@ -247,6 +247,20 @@ function WebsocketMessageService($rootScope, websocketEvents) {
       });
     },
 
+    patchParagraph: function(paragraphId, noteId, patch) {
+      // javascript add "," if change contains several patches
+      // but java library requires patch list without ","
+      patch = patch.replace(/,@@/g, '@@');
+      return websocketEvents.sendNewEvent({
+        op: 'PATCH_PARAGRAPH',
+        data: {
+          id: paragraphId,
+          noteId: noteId,
+          patch: patch,
+        },
+      });
+    },
+
     importNote: function(note) {
       websocketEvents.sendNewEvent({
         op: 'IMPORT_NOTE',

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/c972e257/zeppelin-zengine/src/main/java/org/apache/zeppelin/notebook/socket/Message.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/main/java/org/apache/zeppelin/notebook/socket/Message.java
b/zeppelin-zengine/src/main/java/org/apache/zeppelin/notebook/socket/Message.java
index 2d6a153..1daa008 100644
--- a/zeppelin-zengine/src/main/java/org/apache/zeppelin/notebook/socket/Message.java
+++ b/zeppelin-zengine/src/main/java/org/apache/zeppelin/notebook/socket/Message.java
@@ -19,6 +19,7 @@ package org.apache.zeppelin.notebook.socket;
 
 import com.google.gson.Gson;
 import org.apache.zeppelin.common.JsonSerializable;
+import org.slf4j.Logger;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -187,7 +188,9 @@ public class Message implements JsonSerializable {
     SAVE_NOTE_FORMS,              // save note forms
     REMOVE_NOTE_FORMS,            // remove note forms
     INTERPRETER_INSTALL_STARTED,  // [s-c] start to download an interpreter
-    INTERPRETER_INSTALL_RESULT    // [s-c] Status of an interpreter installation
+    INTERPRETER_INSTALL_RESULT,   // [s-c] Status of an interpreter installation
+    COLLABORATIVE_MODE_STATUS,    // [s-c] collaborative mode status
+    PATCH_PARAGRAPH               // [c-s][s-c] patch editor text
   }
 
   private static final Gson gson = new Gson();
@@ -216,6 +219,15 @@ public class Message implements JsonSerializable {
     return (T) data.get(key);
   }
 
+  public <T> T getType(String key, Logger LOG) {
+    try {
+      return getType(key);
+    } catch (ClassCastException e) {
+      LOG.error("Failed to get " + key + " from message (Invalid type). " , e);
+      return null;
+    }
+  }
+
   @Override
   public String toString() {
     final StringBuilder sb = new StringBuilder("Message{");


Mime
View raw message