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)  ### Questions: * Does the licenses files need update? no * Is there breaking changes for older versions? no * Does this needs documentation? no Author: Savalek <[email protected]> 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 <[email protected]> Authored: Fri Jun 1 15:58:27 2018 +0300 Committer: Jongyoul Lee <[email protected]> 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{");
