From 4785b6e34b38e098a7ffc7aba9ce3b343ba9ecc5 Mon Sep 17 00:00:00 2001 From: Martin Wang Date: Thu, 23 Feb 2017 19:14:13 -0500 Subject: [PATCH 01/37] Collaboration feature Patch for collaboration feature. See https://github.com/Martin1994/orion.collab for commit histories. Change-Id: I2cdc66cbcc01eb1932d31846711efaeaa7cf74dd Signed-off-by: Martin Wang Also-by: Amr Mourad --- .../org.eclipse.orion.client.collab/.project | 23 + .../META-INF/MANIFEST.MF | 8 + .../about.html | 29 + .../build.properties | 16 + .../bundle.properties | 2 + .../org.eclipse.orion.client.collab/pom.xml | 31 + .../web/README.md | 145 ++ .../web/collab/nls/collabmessages.js | 13 + .../web/collab/nls/root/collabmessages.js | 13 + .../web/collab/plugins/collabPlugin.html | 20 + .../web/collab/plugins/collabPlugin.js | 26 + .../web/img/Auth_diagram.png | Bin 0 -> 6373 bytes .../web/img/hub_server.jpg | Bin 0 -> 63773 bytes .../web/img/shared_workspace_server.jpg | Bin 0 -> 35360 bytes .../web/orion/collab/collabClient.js | 613 ++++++ .../web/orion/collab/collabFileAnnotation.js | 114 + .../web/orion/collab/collabFileCommands.js | 80 + .../collab/collabFileEditingAnnotation.js | 66 + .../web/orion/collab/collabFileImpl.js | 264 +++ .../web/orion/collab/collabPeer.js | 36 + .../web/orion/collab/collabSocket.js | 92 + .../web/orion/collab/ot.js | 1890 +++++++++++++++++ .../web/orion/collab/otAdapters.js | 750 +++++++ .../web/orion/collab/shareProjectClient.js | 62 + .../web/orion/editor/annotations.css | 21 + .../web/orion/editor/annotations.js | 5 + .../web/orion/editor/nls/root/messages.js | 2 +- .../web/orion/editor/projectionTextModel.js | 11 +- .../web/orion/editor/textModel.js | 3 + .../web/orion/editor/textView.js | 8 +- .../web/css/controls.css | 82 + .../web/orion/edit/nls/root/messages.js | 3 + .../web/orion/editorView.js | 13 +- .../web/orion/explorers/explorer-table.js | 10 +- .../web/orion/explorers/navigatorRenderer.js | 8 +- .../web/orion/fileCommands.js | 3 +- .../web/orion/inputManager.js | 16 +- .../web/orion/uiUtils.js | 22 +- .../web/orion/webui/treetable.js | 188 +- .../web/orion/widgets/nav/common-nav.js | 41 +- .../web/plugins/authenticationPlugin.js | 6 +- modules/orionode.collab.hub/client.js | 73 + modules/orionode.collab.hub/config.js | 37 + modules/orionode.collab.hub/document.js | 340 +++ modules/orionode.collab.hub/package.json | 28 + modules/orionode.collab.hub/server.js | 92 + modules/orionode.collab.hub/session.js | 318 +++ .../orionode.collab.hub/session_manager.js | 139 ++ modules/orionode/index.js | 7 +- .../orionode/lib/shared/db/sharedProjects.js | 287 +++ .../orionode/lib/shared/db/userProjects.js | 184 ++ .../orionode/lib/shared/sharedDecorator.js | 43 + modules/orionode/lib/shared/sharedUtil.js | 142 ++ modules/orionode/lib/shared/tree.js | 354 +++ modules/orionode/lib/sharedWorkspace.js | 66 + modules/orionode/lib/user.js | 13 +- modules/orionode/lib/xfer.js | 27 +- modules/orionode/orion.conf | 1 + modules/orionode/package.json | 1 + .../builder/scripts/orion.build.js | 3 + 60 files changed, 6836 insertions(+), 54 deletions(-) create mode 100644 bundles/org.eclipse.orion.client.collab/.project create mode 100644 bundles/org.eclipse.orion.client.collab/META-INF/MANIFEST.MF create mode 100644 bundles/org.eclipse.orion.client.collab/about.html create mode 100644 bundles/org.eclipse.orion.client.collab/build.properties create mode 100644 bundles/org.eclipse.orion.client.collab/bundle.properties create mode 100644 bundles/org.eclipse.orion.client.collab/pom.xml create mode 100644 bundles/org.eclipse.orion.client.collab/web/README.md create mode 100644 bundles/org.eclipse.orion.client.collab/web/collab/nls/collabmessages.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/collab/nls/root/collabmessages.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.html create mode 100644 bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/img/Auth_diagram.png create mode 100644 bundles/org.eclipse.orion.client.collab/web/img/hub_server.jpg create mode 100644 bundles/org.eclipse.orion.client.collab/web/img/shared_workspace_server.jpg create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileAnnotation.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileEditingAnnotation.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabPeer.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/ot.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js create mode 100644 bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js create mode 100644 modules/orionode.collab.hub/client.js create mode 100644 modules/orionode.collab.hub/config.js create mode 100644 modules/orionode.collab.hub/document.js create mode 100644 modules/orionode.collab.hub/package.json create mode 100644 modules/orionode.collab.hub/server.js create mode 100644 modules/orionode.collab.hub/session.js create mode 100644 modules/orionode.collab.hub/session_manager.js create mode 100644 modules/orionode/lib/shared/db/sharedProjects.js create mode 100644 modules/orionode/lib/shared/db/userProjects.js create mode 100644 modules/orionode/lib/shared/sharedDecorator.js create mode 100644 modules/orionode/lib/shared/sharedUtil.js create mode 100644 modules/orionode/lib/shared/tree.js create mode 100644 modules/orionode/lib/sharedWorkspace.js diff --git a/bundles/org.eclipse.orion.client.collab/.project b/bundles/org.eclipse.orion.client.collab/.project new file mode 100644 index 0000000000..2555a85640 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/.project @@ -0,0 +1,23 @@ + + + org.eclipse.orion.client.collab + + + + + + org.eclipse.wst.jsdt.core.javascriptValidator + + + + + org.eclipse.pde.ManifestBuilder + + + + + + org.eclipse.wst.jsdt.core.jsNature + org.eclipse.pde.PluginNature + + diff --git a/bundles/org.eclipse.orion.client.collab/META-INF/MANIFEST.MF b/bundles/org.eclipse.orion.client.collab/META-INF/MANIFEST.MF new file mode 100644 index 0000000000..690b37f6c7 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/META-INF/MANIFEST.MF @@ -0,0 +1,8 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: %Bundle-Name +Bundle-SymbolicName: org.eclipse.orion.client.collab +Bundle-Version: 1.0.0.qualifier +Bundle-ActivationPolicy: lazy +Bundle-Vendor: %Bundle-Vendor +Bundle-Localization: bundle diff --git a/bundles/org.eclipse.orion.client.collab/about.html b/bundles/org.eclipse.orion.client.collab/about.html new file mode 100644 index 0000000000..d119ec8706 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/about.html @@ -0,0 +1,29 @@ + + + + +About + + +

About This Content

+ +

April 26, 2011

+

License

+ +

The Eclipse Foundation makes available all content in this plug-in ("Content"). Unless otherwise +indicated below, the Content is provided to you under the terms and conditions of the +Eclipse Public License Version 1.0 +("EPL"), and the +Eclipse Distribution License Version 1.0 ("EDL"). +For purposes of the EPL and EDL, "Program" will mean the Content.

+ +

If you did not receive this Content directly from the Eclipse Foundation, the Content is +being redistributed by another party ("Redistributor") and different terms and conditions may +apply to your use of any object code in the Content. Check the Redistributor's license that was +provided with the Content. If no such license exists, contact the Redistributor. Unless otherwise +indicated below, the terms and conditions of the EPL still apply to any source code in the Content +and such source code may be obtained at http://www.eclipse.org.

+ + + \ No newline at end of file diff --git a/bundles/org.eclipse.orion.client.collab/build.properties b/bundles/org.eclipse.orion.client.collab/build.properties new file mode 100644 index 0000000000..fdb7c5a04c --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/build.properties @@ -0,0 +1,16 @@ +############################################################################### +# Copyright (c) 2011 IBM Corporation and others. +# All rights reserved. This program and the accompanying materials are made +# available under the terms of the Eclipse Public License v1.0 +# (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution +# License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). +# +# Contributors: IBM Corporation - initial API and implementation +############################################################################### + +bin.includes = META-INF/,\ + web/,\ + bundle.properties,\ + about.html +src.includes = web/,\ + about.html diff --git a/bundles/org.eclipse.orion.client.collab/bundle.properties b/bundles/org.eclipse.orion.client.collab/bundle.properties new file mode 100644 index 0000000000..caf1d048f5 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/bundle.properties @@ -0,0 +1,2 @@ +Bundle-Vendor = Eclipse.org - Orion +Bundle-Name = Orion Collab UI \ No newline at end of file diff --git a/bundles/org.eclipse.orion.client.collab/pom.xml b/bundles/org.eclipse.orion.client.collab/pom.xml new file mode 100644 index 0000000000..a30de68b82 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + org.eclipse.orion + org.eclipse.orion.client.collab + 1.0.0-SNAPSHOT + eclipse-plugin + + + org.eclipse.orion + org.eclipse.orion.client.parent + 1.0.0-SNAPSHOT + ../.. + + + \ No newline at end of file diff --git a/bundles/org.eclipse.orion.client.collab/web/README.md b/bundles/org.eclipse.orion.client.collab/web/README.md new file mode 100644 index 0000000000..70daa8a22b --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/README.md @@ -0,0 +1,145 @@ +## Orion Collab + +Collab mode includes a few components. +* [Server-side shared workspace][shared workspace] +* [Client-side collab (OT & shared workspace)][client side] +* [WebSocket Server][websocket server] +* [Authentication (using jwt)][auth] + +### Server-side shared workspace implementation +Server component for loading a user's shared workspace content, for handling file system operations within the shared workspace and for interacting with the database. The following files are responsible for the work: + +- ***orionode/lib/sharedWorkspace.js***: Entry point for all shared operations. + + +- ***orionode/lib/shared/tree.js***: Handles file operations originating from the shared workspace (GET/PUT/POST/DELETE files and folders). + + +- ***orionode/lib/db/sharedProjects.js***: Database interface with the sharedProjects collection. Table includes list of shared projects with the users and hubID (unique project ID used for websocket connection). + + +- ***orionode/lib/db/userProjects.js***: Database interface with the userProjects collection. Table includes orion users with list of shared projects they have access to. + + +- ***orionode/lib/shared/sharedUtil.js***: Utility methods used by the shared workspace files. + + +- ***orionode/lib/shared/sharedDecorator.js***: Decorator that adds the hubID to the file metadata if its a shared project. + +![Orion shared workspace diagram (server)](./img/shared_workspace_server.jpg) + + TODO: + 1. Combine userprojects table with orionaccounts or at least check for user existence on invite. + 2. Better error handling for db queries. + 3. Since projects are uniquely identified by their path, if project gets deleted/renamed, cross-reference to database. + 4. Hub server api (tree/load tree/save) and file decorator (hub id) in single user mode + +### Client-side collab (OT & shared workspace implementation) +As the editor loads (orion/ui/editorView.js), the collabClient is initialized ```new collabClient(editor, inputManager, fileClient)```. Once a project is selected, if it is a shared project, the client connects to the websocket with the project's hubID ```new WebSocket("ws://hubserver/hubID")``` and the collabClient starts catching and sending messages. Once a document is selected and the textView loads, the OT is started. + +``` +collab/web +│ README.md +│ +└───collab +│ └───plugins +│ │ collabPlugin.html +│ │ collabPlugin.js +│ +└───orion +│ └───collab +│ │ shareProjectClient.js Interface for requests to sharedWorkspace database endpoints (for invite/share project). +│ │ collabFileImpl.js File client implementation for shared workspace (create/delete file, fetch children, etc). +│ │ collabSocket.js Communication socket to hub server +│ │ ot.js OT library (third party, unmodified). Provides concurrent collaboration operations and a undo/redo stack. +| | otAdapters.js Adapter classes for OT to interact with Orion +│ │ collabClient.js Entry point of client side collaboration feature +| | collabFileAnnotation.js Collab file annotation used in file nav tree +| | collabPeer.js Collab peer class +| | collabFileCommands.js Defines "share/unshare" project commands + +``` + + +- ***collab/collabClient.js***: Main file for client-side collaboration. Takes in the editor and textView and makes things happen like annotations, prevent auto-save/auto-load, tree updates, etc. Handles doc-level messages (operations, selections, acknowledgement, etc), and starts the OT session. + +![Orion shared workspace diagram (client)]() + + TODO: + 1. Full UI for invite/share + 2. Single user mode collaboration + 3. (Bug) File operations from collab peers shouldn't close the current user's context menu + 4. Edit Selection to move along with the Undo stack + 5. Add quick progress screen/bar while connecting/authenticating to socket to give users context + 6. Progress screen while fetching document content + 7. Connect to socket directly after sharing a project, not just on project selection + 8. (Bug) Figure out how to deal with code folding and split window + 9. Handle socket failure (prevent non-owner from editing?) + 10. Add trigger to enable/disable shared mode + 11. Support MarkdownEditor + 12. Support local selection persistence + +### WebSocket Server +Clients connect to the server with a session ID that represents the selected project. The WebSocket server has 3 layers and the messages flow in a single direction down the layers: + +1. Server (main layer) + * Websocket interface (entry point) + * Handles new connections + * Creates sessions based on session ID + * Sends incoming messages down to appropriate session +2. Session (project layer) + * Manages and keeps track of users currently within that session + * Creates and and keeps track of active documents + * Deals with session-level messages + * Delegates doc-level messages down to the appropriate document +3. Document layer + * Keeps track of users within that document + * Loads document from the Orion server + * Saves document to the filesystem through the Orion server + * Makes use of OT and keeps latest version of file in memory + * Deals with doc-specific messages (operation/selection/etc) + +![WebSocket example diagram](./img/hub_server.jpg) + + TODO: + 1. Make it easy to switch between WebSocket implementation (i.e. socketIO) + 2. Make generic send message method (related to point number 1) + 3. Give client notice if file load/save fails + +### Authentication +A JSON webtoken is generated using a secret and including the username in the payload on user login. The token is saved in the browser local storage and sent to the websocket server upon connection. The websocket server decodes the token and ensures the person is infact an Orion user. + +This requires that the Orion server (orion.conf) and WebSocket server (config file) both have the same JWT secret. + +![Auth diagram](./img/Auth_diagram.png) + + TODO: + 1. The socket server should send the decoded user to the orion server in order to ensure that the user is allowed to the particular project they are trying to access. + 2. Test for all authentication flows (github, google, etc.) + 3. Let jwt be stored in httpOnly cookie instead of localStorage for security reason. This requires the ws server to run under the same domain as the orion server. The ws server can get the authentication token from the cookie in the ws handshake request. As a side effect, this also simplifies the authentication steps because the ws connection got authnticated as soon as the connection is established. + +### Setting up Collab mode +1. Ensure the jwt secret is the same on both Orion and the WS Server. +2. Link the WS server to Orion by setting the Orion url in the config file. +3. Link Orion client to WS server by adding this in defaults.pref: "/collab": { "hubUrl": "YOUR_URL" }. +4. Add `./bundles/org.eclipse.orion.client.collab/web` to `append.static.assets` in `orion.conf`. +5. Add this endpoint at additional.endpoint in orion.conf: +~~~json +{ + "endpoint": "/sharedWorkspace", + "module": "./lib/sharedWorkspace", + "extraOptions": { + "root": "/sharedWorkspace/tree/file", + "fileRoot": "/file" + } +} +~~~ +6. Enable `collab/plugins/collabPlugin.html` plugin by adding it to defaults.pref. +7. Run both servers. +8. Log in to Orion. +9. That's it :) + +[shared workspace]: #sw-section +[client side]: #cs-section +[websocket server]: #ws-section +[auth]: #auth-section diff --git a/bundles/org.eclipse.orion.client.collab/web/collab/nls/collabmessages.js b/bundles/org.eclipse.orion.client.collab/web/collab/nls/collabmessages.js new file mode 100644 index 0000000000..cc1cb0d3a6 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/collab/nls/collabmessages.js @@ -0,0 +1,13 @@ +/******************************************************************************* + * @license + * Copyright (c) 2014 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + ******************************************************************************/ +/* eslint-env amd */ +define({ + root:true +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/collab/nls/root/collabmessages.js b/bundles/org.eclipse.orion.client.collab/web/collab/nls/root/collabmessages.js new file mode 100644 index 0000000000..4ca1b215f3 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/collab/nls/root/collabmessages.js @@ -0,0 +1,13 @@ +/******************************************************************************* + * @license + * Copyright (c) 2016 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + ******************************************************************************/ +/*eslint-env browser, amd*/ +define({//Default message bundle + "SharedWorkspace": "Shared Workspace" //$NON-NLS-0$ //$NON-NLS-1$ +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.html b/bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.html new file mode 100644 index 0000000000..36977335da --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.html @@ -0,0 +1,20 @@ + + + + + + + + + + +

Orion Collaboration Plugin

+

This plugin provides a shared workspace with collaboration editing feature.

+ + diff --git a/bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.js b/bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.js new file mode 100644 index 0000000000..f48410e72c --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/collab/plugins/collabPlugin.js @@ -0,0 +1,26 @@ +define([ + "orion/plugin", + 'i18n!collab/nls/collabmessages', + "orion/Deferred", + "orion/i18nUtil", + 'orion/EventTarget', + "orion/collab/collabFileImpl", + "plugins/filePlugin/fileImpl" +], function(PluginProvider, messages, Deferred, i18nUtil, EventTarget, collabFileImpl, FileServiceImpl) { + var headers = { + name: "Orion Collaboration Support", + version: "1.0", + description: "This plug-in provides Orion collaboration feature." + }; + var provider = new PluginProvider(headers); + var collabBase = new URL("../../sharedWorkspace/tree", location.href).pathname; + var service = new collabFileImpl(collabBase); + // var service = new FileServiceImpl('/sharedWorkspace/tree', '/sharedWorkspace/tree'); + provider.registerService("orion.core.file", service, { + Name: messages["SharedWorkspace"], + top: collabBase, + pattern: collabBase + }); + + provider.connect(); +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/img/Auth_diagram.png b/bundles/org.eclipse.orion.client.collab/web/img/Auth_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..96a2c9d304d448a54c0811477f8a1644bd78aad4 GIT binary patch literal 6373 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 z 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 z z*t*idzgA}jKxZq8w-8N}(9a+;y@}*nBs)c0-`-`GDkcD(yxBp-dJ!))BsTi`Ht*8| z)>ayBrI&@y&4Q=gPZ!M{koNw(gWig#E}V5~s(r6rH5d*F#Kvb@>14|;qYvJwxSxSm z@6}(cXQV!4jl;PR;Q#8Vi8X){hk>`;8T*BKnO|mv)rLozNu+S*$@7Ua_si`TSTr$Q?Q)h*k$ru%!sQ+FGPG*UPobc>{sdzE5Y)i~WEuC2;5JB+!+9=IXAw^+>2Ks!9r$+8CbW$#NV!-v#G$gpL2mZZ1sX zPJI2K>)F%dc1nAl%P1S0qS}12zRKAJ1-!kpE?>we-TQMY^?qnQ@);VjZGpjmqQKFw zzYMhTq};0=>KD#lD12~@?k8DCGuR@u`Zpy6a{`jPNRO!1vA=gCcOIrMiORC0=%*Jw zzVXX-pb#GcAcno-yrc7Uo|0)3i%A{IR&ihB8k|0AM(l@K$I5_JS{90XW!(Q{Q3LvP z$qQ;gTrtXPT}#Ca&$Oyq*WrUve3nk40U~g$2E(J66D3T@8I#;wW0c19-}fD#c1Z&1 z!<%X*%gkW2s`cxQb+b>SXgD8fszR;WP4m}GwMipIWc`YTV$Hc#kb0jcjOSk+!{wGf zkA88SGFLH+A#?VsaQ!FBo5`t{IK}p*ls#2tKTH-noF9kN$4WpmD!zvO@wgwjhT5B# z>BSD>+Rh;>+p95>ep>@m3#}Q)!v?d5QM~jdz;;!73068NJtRfI~l+{Y_|O1HNA@V_D`_jEV>h78~-d(muI)6 z5a&e<*7v8tP2rCV_-&I<6EAe9^ANxCzo3Cs0oq(1$G>ttQl$kcT-|~Z1dqnj?a(Xu zIc{#mYK3<3@?0yE^J>_?wC2d~kdfw|ILC=tMstqCZ%~&hg#`&uL$w78i+B2wj$A)h8M)*ua6jA+0t;_+viA zE0sbAVFL3%Gc4wBLx>6|KBAgn7wW3Ttj`f<=HC@N$y{liNnYf)VeZ{e5ooHEbQNr4 zh2BW?O*2f?i)x$jZ6|OW8S}b3cof%$-jzRV>RBr*Rm~Xh(<(2vmM^NM^?h@Pzv39Q z@HRiae2;-?-e(fj*}Y*d(lZ`>P*KnN&3GHsR)s_Xxmm>IpX}0H@H;>VK91mN)m}nh z8q?;eH%x2WPdvz{x=;aEnX78kv1m5TpJ0O$vU`!jroy$P4B&IR_b4K(A|qoZ50p8* zZAyj;v6OR<9x-I!nIJ%{ggTI-X1PG<%0C?G>CTNrfZS1o29blTC&BDAP|hHg7cW*b zh8hk%qqu9EB;_6$FZj!CzO(P&mui1S_N>-6B<*G>0UmsW#m6H7I7(K`=ow2z16n(6bi>X6)Gs0{~^UZP{yh7JEL#RRFIZe_p zy<8n0JgSajTm%C&#K#J{RhQAJR6)bJ+tzogrD$4&@)lLedJ`!TH{|a@mEO;HmoOUv-R^%t>+plfiJmF{=zP+z! z(Kdm5M7Ym9WM#!8uss)n>(W7~;hXnvw?e-7Mga`ZC?mM++u04JEpiGcR(4S=@>f?XV5d{9y8nYc|!{C z^^P8b`HN)-$v!2R8A7jo7&)Ep#RD0)hbRJ=2a>bPL>dek%LJ1`NEAePqYUU3Pev)v z%5&=yw`VuJ_)=X8M1}N#h);kZWtJcXYm-Y8U4Qd|3(eGP?W>LbSux9+x?@&U-TNjA zc?pKZUALqW01lau2CTs(!7-sMU8bA|1GBI{YdC~_sukDK?+DWDM@SKf^%tP$YUP&as zs(y~0*L76Q;RK|j;ODMCf${MhLHRIad*T^xb1<`fZ0{=lVLRaueJ#OANn5i-+ufH Dm|Ld- literal 0 HcmV?d00001 diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js new file mode 100644 index 0000000000..3c2e7dc499 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js @@ -0,0 +1,613 @@ +/******************************************************************************* + * @license + * Copyright (c) 2016, 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/otAdapters', + 'orion/collab/collabPeer', 'orion/collab/collabSocket', 'orion/collab/collabFileCommands', + 'orion/collab/collabFileEditingAnnotation'], + function(ot, mCollabFileAnnotation, mOtAdapters, mCollabPeer, mCollabSocket, mCollabFileCommands, + mCollabFileEditingAnnotation) { + + 'use strict'; + + var mAnnotations; + var mTreeTable; + var AT; + + var contextPath = location.pathname.substr(0, location.pathname.lastIndexOf('/edit/edit.html')); + // This is a workaround of split window. Basically this guid let the collab client ignore all file operations and text operations from the same guid. + // However, when there are two split windows on a same file, a text operation will still be sent twice, which needs to be fixed. (TODO) + var guid = Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16) + '.' + Math.floor(Math.random() * 0x100000000).toString(16); + + function init(callback) { + require(['orion/editor/annotations', 'orion/webui/treetable'], function(_mAnnotations, _mTreeTable) { + mAnnotations = _mAnnotations; + mTreeTable = _mTreeTable; + AT = mAnnotations.AnnotationType; + mCollabFileCommands.init(function() { + callback(); + }); + }); + } + + var CollabFileAnnotation = mCollabFileAnnotation.CollabFileAnnotation; + var CollabFileEditingAnnotation = mCollabFileEditingAnnotation.CollabFileEditingAnnotation; + var OrionCollabSocketAdapter = mOtAdapters.OrionCollabSocketAdapter; + var OrionEditorAdapter = mOtAdapters.OrionEditorAdapter; + var CollabPeer = mCollabPeer.CollabPeer; + + // ms to delay updating collaborator annotation. + // We need this delay because the annotation is updated asynchronizedly and is transferd in multiple + // packages. We don't want the UI to refresh too frequently. + var COLLABORATOR_ANNOTATION_UPDATE_DELAY = 50; + + var SOCKET_RECONNECT_MAX_ATTEMPT = 5; + var SOCKET_RECONNECT_DELAY = 100; + var SOCKET_PING_TIMEOUT = 25000; + + /** + * Creates a new collaboration client. + * @class + * @name orion.collabClient.CollabClient + */ + function CollabClient(editor, inputManager, fileClient, serviceRegistry, commandRegistry, preferences) { + mCollabFileCommands.createFileCommands(serviceRegistry, commandRegistry, fileClient); + this.editor = editor; + this.inputManager = inputManager; + this.fileClient = fileClient; + this.preferences = preferences; + this.textView = this.editor.getTextView(); + var self = this; + this.collabMode = false; + this.clientId = ''; + this.clientDisplayedName = ''; + this.fileClient.addEventListener('Changed', self.sendFileOperation.bind(self)); + this.serviceRegistry = serviceRegistry; + this.editor.addEventListener('InputContentsSet', function(event) {self.viewInstalled.call(self, event);}); + this.editor.addEventListener('TextViewUninstalled', function(event) {self.viewUninstalled.call(self, event);}); + this.projectSessionID = ''; + this.inputManager.addEventListener('InputChanged', function(e) { + self.onInputChanged(e) + }); + this.ot = null; + this.otOrionAdapter = null; + this.otSocketAdapter = null; + this.socketReconnectAttempt = 0; + this.socketIntentionalyClosing = false; + this.socketPingTimeout = 0; + this.awaitingClients = false; + this.collabFileAnnotations = {}; + // Timeout id to indicate whether a delayed update has already been assigned + this.collabFileAnnotationsUpdateTimeoutId = 0; + /** + * A map of clientid -> peer + * @type {Object.} + */ + this.peers = {}; + this.editing = false; + this.guid = guid; + // Initialize current project + var file = this.inputManager.getInput(); + var metadata = this.inputManager.getFileMetadata(); + if (metadata) { + this.onInputChanged({ + metadata: metadata, + input: { + resource: file + } + }); + } + } + + CollabClient.prototype = { + + /** + * getter of clientId + * @return {string} + */ + getClientId: function() { + return this.clientId; + }, + + /** + * getter of clientDisplayedName + * @return {string} + */ + getClientDisplayedName: function() { + return this.clientDisplayedName; + }, + + /** + * Initialize client name and id + * @param {function} callback + */ + initClientInfo: function(callback) { + var self = this; + var userService = this.serviceRegistry.getService("orion.core.user"); + var authServices = this.serviceRegistry.getServiceReferences("orion.core.auth"); + var authService = this.serviceRegistry.getService(authServices[0]); + authService.getUser().then(function(jsonData) { + userService.getUserInfo(contextPath + jsonData.Location).then(function(accountData) { + var username = accountData.UserName; + self.clientDisplayedName = accountData.FullName || username; + var MASK = 0xFFFFFF + 1; + var MAGIC = 161803398 / 2 % MASK; + self.clientId = username + '.' + (Date.now() % MASK * MAGIC % MASK).toString(16); + callback(); + }, function(err) { + console.error(err); + }); + }); + }, + + /** + * Input changed handler + */ + onInputChanged: function(e) { + this.location = e.input.resource; + this.updateSelfFileAnnotation(); + this.destroyOT(); + this.sendCurrentLocation(); + if (e.metadata.Attributes) { + var projectSessionID = e.metadata.Attributes.hubID; + if (this.projectSessionID !== projectSessionID) { + this.projectSessionID = projectSessionID; + this.projectChanged(projectSessionID); + } + } + }, + + /** + * Send current location to collab peers + */ + sendCurrentLocation: function() { + if (this.otSocketAdapter && this.otSocketAdapter.authenticated) { + this.otSocketAdapter.sendLocation(this.currentDoc()); + } + }, + + /** + * Reset the record of collaborator file annotation and request to update UI + */ + resetCollabFileAnnotation: function() { + this.collabFileAnnotations = {}; + this._requestFileAnnotationUpdate(); + }, + + /** + * Add or update a record of collaborator file annotation and request to update UI + * + * @param {string} clientId + * @param {string} url + * @param {boolean} editing + */ + addOrUpdateCollabFileAnnotation: function(clientId, url, editing) { + if (url) { + url = this.maybeTransformLocation(url); + } + var peer = this.getPeer(clientId); + // Peer might be loading. Once it is loaded, this annotation will be automatically updated, + // so we can safely leave it blank. + var name = (peer && peer.name) ? peer.name : 'Unknown'; + var color = (peer && peer.color) ? peer.color : '#000000'; + this.collabFileAnnotations[clientId] = new CollabFileAnnotation(name, color, url, this.projectRelativeLocation(url), editing); + this._requestFileAnnotationUpdate(); + }, + + /** + * Remove a collaborator's file annotation by id and request to update UI + * + * @param {string} clientId - + */ + removeCollabFileAnnotation: function(clientId) { + if (this.collabFileAnnotations.hasOwnProperty(clientId)) { + delete this.collabFileAnnotations[clientId]; + this._requestFileAnnotationUpdate(); + } + }, + + /** + * Request a file annotation UI update + */ + _requestFileAnnotationUpdate: function() { + var self = this; + if (!this.collabFileAnnotationsUpdateTimeoutId) { + // No delayed update is assigned. Assign one. + // This is necessary because we don't want duplicate UI action within a short period. + this.collabFileAnnotationsUpdateTimeoutId = setTimeout(function() { + self.collabFileAnnotationsUpdateTimeoutId = 0; + var annotations = []; + var editingFileUsers = {}; // map from location to list of usernames indicating all users that are typing on this file + for (var key in self.collabFileAnnotations) { + if (self.collabFileAnnotations.hasOwnProperty(key)) { + var annotation = self.collabFileAnnotations[key]; + annotations.push(annotation); + if (annotation.editing) { + if (!editingFileUsers[annotation.location]) { + editingFileUsers[annotation.location] = []; + } + editingFileUsers[annotation.location].push(annotation.name); + } + } + } + // Add editing annotations + for (var location in editingFileUsers) { + if (editingFileUsers.hasOwnProperty(location)) { + annotations.push(new CollabFileEditingAnnotation(location, editingFileUsers[location])); + } + } + self.fileClient.dispatchEvent({ + type: 'AnnotationChanged', + removeTypes: [CollabFileAnnotation], + annotations: annotations + }); + }, COLLABORATOR_ANNOTATION_UPDATE_DELAY); + } + }, + + /** + * Determine whether a client has a file annotation + * + * @return {boolean} - + */ + collabHasFileAnnotation: function(clientId) { + return !!this.collabFileAnnotations[clientId]; + }, + + /** + * Get the client's file annotation + * + * @return {CollabFileAnnotation} - + */ + getCollabFileAnnotation: function(clientId) { + return this.collabFileAnnotations[clientId]; + }, + + /** + * Update the current client's file annotation + */ + updateSelfFileAnnotation: function() { + if (this.collabMode) { + this.addOrUpdateCollabFileAnnotation(this.getClientId(), this.maybeTransformLocation(contextPath + '/file/' + this.currentDoc()), this.editing); + } + }, + + /** + * Add or update peer record + * + * @param {CollabPeer} peer - + */ + addOrUpdatePeer: function(peer) { + if (this.peers[peer.id]) { + // Update + this.peers[peer.id] = peer; + } else { + // Add + this.peers[peer.id] = peer; + } + if (this.collabHasFileAnnotation(peer.id)) { + var annotation = this.getCollabFileAnnotation(peer.id); + this.addOrUpdateCollabFileAnnotation(peer.id, annotation.location); + } + if (this.otOrionAdapter && this.textView) { + // Make sure we have view installed + this.otOrionAdapter.updateLineAnnotationStyle(peer.id); + } + }, + + /** + * Get peer by id + * + * @return {CollabPeer} - + */ + getPeer: function(clientId) { + return this.peers[clientId]; + }, + + /** + * Get all peers + */ + getAllPeers: function(clientId) { + return this.peers; + }, + + /** + * Remove a peer by its ID if it exists + * This method also removes all the removing client's annotation + */ + removePeer: function(clientId) { + if (this.peers.hasOwnProperty(clientId)) { + delete this.peers[clientId]; + this.removeCollabFileAnnotation(clientId); + } + }, + + /** + * Remove all peers + */ + clearPeers: function() { + this.peers = {}; + }, + + startOT: function(revision, operation, clients) { + if (this.ot) { + this.otOrionAdapter.detach(); + } + this.textView.getModel().setText(operation[0], 0); + this.otOrionAdapter = new OrionEditorAdapter(this.editor, this, AT); + this.ot = new ot.EditorClient(revision, clients, this.otSocketAdapter, this.otOrionAdapter, this.getClientId()); + // Give initial cursor position + this.otOrionAdapter.onFocus(); + this.editor.markClean(); + }, + + destroyOT: function() { + if (this.ot && this.otOrionAdapter) { + this.otOrionAdapter.detach(); + //reset to regular undo/redo behaviour + if (this.textView) { + this.editor.getTextActions().init(); + } + this.ot = null; + if (this.otSocketAdapter) { + var msg = { + 'type': 'leave-document', + 'clientId': this.getClientId() + }; + this.otSocketAdapter.send(JSON.stringify(msg)); + } + } + }, + + currentDoc: function() { + var workspace = this.getFileSystemPrefix(); + if (workspace !== '/file/') { + //get everything after 'workspace name' + return this.location.substring(this.location.indexOf(workspace) + workspace.length).split('/').slice(3).join('/'); + } else { + return this.location.substring(this.location.indexOf(workspace) + workspace.length, this.location.length); + } + }, + + getFileSystemPrefix: function() { + return this.location.substr(contextPath.length).indexOf('/sharedWorkspace') === 0 ? '/sharedWorkspace/tree/file/' : '/file/'; + }, + + viewInstalled: function(event) { + var self = this; + var ruler = this.editor._annotationRuler; + ruler.addAnnotationType(AT.ANNOTATION_COLLAB_LINE_CHANGED, 1); + ruler = this.editor._overviewRuler; + ruler.addAnnotationType(AT.ANNOTATION_COLLAB_LINE_CHANGED, 1); + this.textView = this.editor.getTextView(); + if (this.otSocketAdapter) { + this.otSocketAdapter.sendInit(); + } + }, + + viewUninstalled: function(event) { + this.textView = null; + this.destroyOT(); + }, + + socketConnected: function() { + var self = this; + this.otSocketAdapter = new OrionCollabSocketAdapter(this, this.socket); + this.socketReconnectAttempt = 0; + if (!this.clientId) { + this.initClientInfo(function() { + self.otSocketAdapter.authenticate(); + }); + } else { + this.otSocketAdapter.authenticate(); + } + this.inputManager.syncEnabled = false; + this.issueNextPing(); + }, + + socketDisconnected: function() { + this.socket = null; + this.otSocketAdapter = null; + this.inputManager.syncEnabled = true; + this.destroyOT(); + this.resetCollabFileAnnotation(); + if (!this.socketIntentionalyClosing) { + if (this.socketReconnectAttempt < SOCKET_RECONNECT_MAX_ATTEMPT) { + var self = this; + this.socketReconnectAttempt++; + setTimeout(function() { + self.projectChanged(self.projectSessionID); + }, SOCKET_RECONNECT_DELAY); + } else { + console.error('Network error. Cannot enable collaboration feature.'); + } + } + }, + + issueNextPing: function() { + var self = this; + if (this.socketPingTimeout) { + clearTimeout(this.socketPingTimeout); + } + this.socketPingTimeout = setTimeout(function() { + self.socketPingTimeout = 0; + if (self.socket) { + self.socket.send(JSON.stringify({ + type: 'ping' + })); + self.issueNextPing(); + } + }, SOCKET_PING_TIMEOUT); + }, + + projectChanged: function(projectSessionID) { + var self = this; + if (this.socket) { + // Close the current session + this.socketIntentionalyClosing = true; + this.socket.close(); + setTimeout(function() { + // Polling until 'close' event is triggered + // 'close' event won't wait for any IO operations thus + // this.socket should be null in the next event loop + self.projectChanged(projectSessionID); + }, 0); + return; + } + this.socketIntentionalyClosing = false; + // Initialize collab socket + if (projectSessionID) { + this.preferences.get("/collab").then(function(collabOptions) { + self.socket = new mCollabSocket.CollabSocket(collabOptions.hubUrl, projectSessionID); + self.socket.addEventListener('ready', function onReady() { + self.socket.removeEventListener('ready', onReady); + self.socketConnected(); + }); + self.socket.addEventListener('close', function onClose() { + self.socket.removeEventListener('close', onClose); + self.socketDisconnected(); + }); + }); + this.collabMode = true; + } else { + this.collabMode = false; + } + this.clearPeers(); + this.resetCollabFileAnnotation(); + }, + + sendFileOperation: function(evt) { + if (!this.otSocketAdapter) return; + if (!this.ignoreNextFileOperation) { + var operation = evt.created ? 'created' : evt.moved ? 'moved' : evt.deleted ? 'deleted' : evt.copied ? 'copied' : ''; + if (operation) { + var msg = { + 'type': 'file-operation', + 'operation': operation, + 'data': evt[operation], + 'clientId': this.getClientId(), + 'guid': guid + }; + this.otSocketAdapter.send(JSON.stringify(msg)); + } + } + this.ignoreNextFileOperation = false; + }, + + handleFileOperation: function(msg) { + if (!this.ignoreNextFileOperation) { + if (msg.guid !== guid) { + var evt = this.makeFileClientEvent(msg.operation, msg.data); + this.dispatchFileClientEvent(evt); + } + } + this.ignoreNextFileOperation = false; + }, + + /** + * Make a event for FileClient by data received from peers. The event + * That this method made also prevent UI changes (e.g. expanding the + * file tree). + * + * @param {stirng} operation + * @param {Array} data + */ + makeFileClientEvent: function(operation, data) { + /** + ** we can't trigger the event directly since the user might be on a seperate file system. + */ + data = data[0]; + var evt = { + type: 'Changed', + silent: true + }; + + var evtData = {'select': false}; + + switch (operation) { + case 'created': + var parentLocation = this.maybeTransformLocation(data.parent); + var result = data.result; + if (result) { + result.Parents = []; //is parents even needed for this operation? + result.Location = this.maybeTransformLocation(result.Location); + } + evt.created = [{'parent': parentLocation, 'result': result, 'eventData': evtData}]; + break; + case 'deleted': + var deleteLocation = this.maybeTransformLocation(data.deleteLocation); + evt.deleted = [{'deleteLocation': deleteLocation, 'eventData': evtData}]; + break; + case 'moved': + var sourceLocation = this.maybeTransformLocation(data.source); + var targetLocation = this.maybeTransformLocation(data.target); + var result = data.result; + result.Parents = []; //is parents even needed for this operation? + result.Location = this.maybeTransformLocation(result.Location); + evt.moved = [{'source': sourceLocation, 'target': targetLocation, 'result': result, 'eventData': evtData}]; + break; + case 'copied': + var sourceLocation = this.maybeTransformLocation(data.source); + var targetLocation = this.maybeTransformLocation(data.target); + var result = data.result; + result.Parents = []; //is parents even needed for this operation? + result.Location = this.maybeTransformLocation(result.Location); + evt.copied = [{'source': sourceLocation, 'target': targetLocation, 'result': result, 'eventData': evtData}]; + break; + } + + return evt; + }, + + /** + * For example we potentially need to convert a '/file/web/potato.js' to '/sharedWorkspace/tree/file/web/potato.js' + * and vice-versa, depending on our file system and the sender's filesystem. + */ + maybeTransformLocation: function(Location) { + var loc = this.getFileSystemPrefix(); + // if in same workspace + if (Location.substr(contextPath.length).indexOf(loc) === 0) { + return Location; + } else { + var oppositeLoc = loc == '/file/' ? '/sharedWorkspace/tree/file/' : '/file/'; + // we need to replace sharedWorkspace... with /file and vice versa. + // we also need to replace workspace info for shared workspace or add it when its not the case. + var file = this.projectRelativeLocation(Location); + var currFile = this.projectRelativeLocation(location.hash.substr(1)); + var prefix = location.hash.substr(1, location.hash.lastIndexOf(currFile) - 1); + Location = prefix + file; + return Location; + } + }, + + projectRelativeLocation: function(location) { + if (location.substr(contextPath.length).indexOf('/file/') === 0) { + // Local workspace + return location.substr((contextPath + '/file/').length); + } else { + // Shared workspace + return location.substr(contextPath.length).split('/').slice(7).join('/'); + } + }, + + dispatchFileClientEvent: function(evt) { + this.ignoreNextFileOperation = true; + this.fileClient.dispatchEvent(evt); + } + }; + + CollabClient.prototype.constructor = CollabClient; + + return { + CollabClient: CollabClient, + init: init + }; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileAnnotation.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileAnnotation.js new file mode 100644 index 0000000000..aa5169e20f --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileAnnotation.js @@ -0,0 +1,114 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +define(['orion/uiUtils'], function(mUIUtils) { + + 'use strict'; + + /** + * A record of a collaborator annotation in the file tree + * + * @constructor + * @name {orion.collab.CollabFileAnnotation} + * @implements {orion.treetable.TableTree.IAnnotation} + * + * @param {string} name - displayed name + * @param {string} color - user color + * @param {string} location - file location + * @param {string} displayedLocation - read friendly location + * @param {boolean} editing + */ + var CollabFileAnnotation = function(name, color, location, displayedLocation, editing) { + this.name = name; + this.color = color; + // Remove trailing "/" + if(location.substr(-1) === '/') { + location = location.substr(0, location.length - 1); + } + this.location = location; + this.displayedLocation = displayedLocation || location; + this.editing = editing; + }; + + CollabFileAnnotation.prototype = { + /** + * Find the deepest expanded folder item that contains the file having + * this annotation. + * + * @see IAnnotation for details. + * + * @param {orion.explorer.ExplorerModel} model - + * @param {Function} callback - + */ + findDeepestFitId: function(model, callback) { + var self = this; + model.getRoot(function(root) { + // Find the existing ID reversely + var location = self.location; + while (location.length > 0) { + // Create a fake item + // NOTE: it's a hack because we don't have any efficient + // way to get the actual file node. Instead, we have + // to do it recursively starting from the root. If + // you find anything wierd happens, change it to the + // actual item object. + var item = { + Location: location + }; + var id = model.getId(item); + // Test if this element exists + var exists = !!document.getElementById(id); + if (exists) { + callback(id); + return; + } + // Not found. This probably means this item is collapsed. + // Try to find one level upper. + // Here I assume every url starts from "/" + location = location.substr(0, location.lastIndexOf('/')); + } + // Nothing found + callback(''); + }); + }, + + /** + * Get description of this annotation which can be used in for example + * tooltip. + * + * @return {string} - description + */ + getDescription: function() { + return '' + this.name + ' is editing ' + this.displayedLocation + ''; + }, + + /** + * Generate a new HTML element of this annotation. + * + * @return {Element} - the HTML element of this annotation + */ + generateHTML: function() { + var element = document.createElement('div'); + element.innerHTML = mUIUtils.getNameInitial(this.name); + element.style.backgroundColor = this.color; + element.classList.add('collabAnnotation'); + if (this.editing) { + element.classList.add('collabEditing'); + } + return element; + } + }; + + return { + CollabFileAnnotation: CollabFileAnnotation + }; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js new file mode 100644 index 0000000000..e2ef7615f1 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js @@ -0,0 +1,80 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +define(['orion/collab/shareProjectClient'], function(shareProjectClient) { + + 'use strict'; + + var mCommands; + var mCommandRegistry; + + var CollabFileCommands = Object.create(null); + + CollabFileCommands.init = function(callback) { + require(['orion/commands', 'orion/commandRegistry'], function(_mCommands, _mCommandRegistry) { + mCommands = _mCommands; + mCommandRegistry = _mCommandRegistry; + callback(); + }); + }; + + CollabFileCommands.createFileCommands = function(serviceRegistry, commandService, fileClient) { + var shareProjectCommand = new mCommands.Command({ + name: "Share Project", + tooltip: "Share project with a friend", + description: "Add a user so that they can collaborate with you on the project.", + imageClass: "core-sprite-link", //$NON-NLS-0$ + id: "orion.collab.shareProject", //$NON-NLS-0$ + parameters: new mCommandRegistry.ParametersDescription([new mCommandRegistry.CommandParameter('username', 'text', "Username:", "Enter username here")]), //$NON-NLS-5$ //$NON-NLS-4$ //$NON-NLS-3$ //$NON-NLS-2$ //$NON-NLS-1$ //$NON-NLS-0$ + callback: function(data) { + var username = data.parameters.parameterTable.username.value; + var project = data.items[0].Name; + shareProjectClient.addUser(username, project); + }, + visibleWhen: function(item) { + if (Array.isArray(item)) { + if (item.length === 1) { + return !item[0].Parents || !item[0].Parents.length; + } + } + return false; + } + }); + commandService.addCommand(shareProjectCommand); + + var unshareProjectCommand = new mCommands.Command({ + name: "Unshare Project", + tooltip: "Unshare a project", + description: "Remove a user from the sharing list of this project.", + imageClass: "core-sprite-link", //$NON-NLS-0$ + id: "orion.collab.unshareProject", //$NON-NLS-0$ + parameters: new mCommandRegistry.ParametersDescription([new mCommandRegistry.CommandParameter('username', 'text', "Username:", "Enter username here")]), //$NON-NLS-5$ //$NON-NLS-4$ //$NON-NLS-3$ //$NON-NLS-2$ //$NON-NLS-1$ //$NON-NLS-0$ + callback: function(data) { + var username = data.parameters.parameterTable.username.value; + var project = data.items[0].Name; + shareProjectClient.removeUser(username, project); + }, + visibleWhen: function(item) { + if (Array.isArray(item)) { + if (item.length === 1) { + return !item[0].Parents || !item[0].Parents.length; + } + } + return false; + } + }); + commandService.addCommand(unshareProjectCommand); + + }; + + return CollabFileCommands; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileEditingAnnotation.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileEditingAnnotation.js new file mode 100644 index 0000000000..899476d0d4 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileEditingAnnotation.js @@ -0,0 +1,66 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +define(['orion/collab/collabFileAnnotation'], function(mCollabFileAnnotation) { + + 'use strict'; + + var CollabFileAnnotation = mCollabFileAnnotation.CollabFileAnnotation; + + /** + * An annotation shows that a file is under editing + * + * @constructor + * @name {orion.collab.CollabFileEditingAnnotation} + * @extends {orion.collab.CollabFileAnnotation} + * + * @param {string} location - file location + * @param {Array.} users - list of users that is modifying this file + */ + var CollabFileEditingAnnotation = function(location, users) { + // Remove trailing "/" + if(location.substr(-1) === '/') { + location = location.substr(0, location.length - 1); + } + this.location = location; + console.assert(Array.isArray(users)); + this.users = users; + }; + + CollabFileEditingAnnotation.prototype = Object.create(CollabFileAnnotation.prototype); + + /** + * Get description of this annotation which can be used in for example + * tooltip. + * + * @return {string} - description + */ + CollabFileEditingAnnotation.prototype.getDescription = function() { + return '' + this.users.join(', ') + ' ' + (this.users.length > 1 ? 'are' : 'is') + ' modifying this file.'; + }; + + /** + * Generate a new HTML element of this annotation. + * + * @return {Element} - the HTML element of this annotation + */ + CollabFileEditingAnnotation.prototype.generateHTML = function() { + var element = document.createElement('div'); + element.innerHTML = '···'; + element.classList.add('editingAnnotation'); + return element; + }; + + return { + CollabFileEditingAnnotation: CollabFileEditingAnnotation + }; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js new file mode 100644 index 0000000000..9d48941954 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js @@ -0,0 +1,264 @@ +/******************************************************************************* + * @license + * Copyright (c) 2016 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd*/ +/*global URL*/ +define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], function(xhr, Deferred, _, form) { + + function CollabFileImpl(fileBase) { + this.fileBase = fileBase; + } + + var GIT_TIMEOUT = 60000; + + CollabFileImpl.prototype = { + fetchChildren: function(location) { + var fetchLocation = location; + if (fetchLocation===this.fileBase) { + return new Deferred().resolve([]); + } + //If fetch location does not have ?depth=, then we need to add the depth parameter. Otherwise server will not return any children + if (fetchLocation.indexOf("?depth=") === -1) { //$NON-NLS-0$ + fetchLocation += "?depth=1"; //$NON-NLS-0$ + } + return xhr("GET", fetchLocation,{ //$NON-NLS-0$ + headers: { + "Orion-Version": "1", //$NON-NLS-0$ //$NON-NLS-1$ + "Content-Type": "charset=UTF-8" //$NON-NLS-0$ //$NON-NLS-1$ + }, + timeout: GIT_TIMEOUT + }).then(function(result) { + var jsonData = result.response ? JSON.parse(result.response) : {}; + return jsonData.Children || []; + }); + }, + loadWorkspaces: function() { + return this.loadWorkspace(this._repoURL); + }, + loadWorkspace: function(location) { + var suffix = "/sharedWorkspace/"; + if (location && location.indexOf(suffix, location.length - suffix.length) !== -1) { + location += "tree/"; + } + + return xhr("GET", location,{ //$NON-NLS-0$ + headers: { + "Orion-Version": "1", //$NON-NLS-0$ //$NON-NLS-1$ + "Content-Type": "charset=UTF-8" //$NON-NLS-0$ //$NON-NLS-1$ + }, + timeout: GIT_TIMEOUT + }).then(function(result) { + var jsonData = result.response ? JSON.parse(result.response) : {}; + return jsonData || {}; + }); + }, + createProject: function(url, projectName, serverPath, create) { + throw new Error("Not supported"); //$NON-NLS-0$ + }, + createFolder: function(parentLocation, folderName) { + return xhr("POST", parentLocation, { + headers: { + "Orion-Version": "1", + "X-Create-Options" : "no-overwrite", + "Slug": form.encodeSlug(folderName), + "Content-Type": "application/json;charset=UTF-8" + }, + data: JSON.stringify({ + "Name": folderName, + "LocalTimeStamp": "0", + "Directory": true + }), + timeout: 15000 + }).then(function(result) { + return result.response ? JSON.parse(result.response) : null; + }); + }, + createFile: function(parentLocation, fileName) { + return xhr("POST", parentLocation, { + headers: { + "Orion-Version": "1", + "X-Create-Options" : "no-overwrite", + "Slug": form.encodeSlug(fileName), + "Content-Type": "application/json;charset=UTF-8" + }, + data: JSON.stringify({ + "Name": fileName, + "LocalTimeStamp": "0", + "Directory": false + }), + timeout: 15000 + }).then(function(result) { + return result.response ? JSON.parse(result.response) : null; + }); + }, + deleteFile: function(location) { + return xhr("DELETE", location, { + headers: { + "Orion-Version": "1" + }, + timeout: 15000 + }).then(function(result) { + return result.response ? JSON.parse(result.response) : null; + }); + }, + moveFile: function(sourceLocation, targetLocation, name) { + return this._doCopyMove(sourceLocation, targetLocation, true, name).then(function(result) { + return result; + }); + }, + copyFile: function(sourceLocation, targetLocation, name) { + return this._doCopyMove(sourceLocation, targetLocation, false, name).then(function(result) { + return result; + }); + }, + _doCopyMove: function(sourceLocation, targetLocation, isMove, _name) { + if (!_name) { + //take the last segment (trailing slash will product an empty segment) + var segments = sourceLocation.split("/"); + _name = segments.pop() || segments.pop(); + } + return xhr("POST", targetLocation, { + headers: { + "Orion-Version": "1", + "Slug": form.encodeSlug(_name), + "X-Create-Options": "no-overwrite," + (isMove ? "move" : "copy"), + "Content-Type": "application/json;charset=UTF-8" + }, + data: JSON.stringify({ + "Location": sourceLocation, + "Name": _name + }), + timeout: 15000 + }).then(function(result) { + return result.response ? JSON.parse(result.response) : null; + }); + }, + read: function(loc, isMetadata, acceptPatch, options) { + if (typeof acceptPatch === 'object') { + options = acceptPatch; + acceptPatch = false; + } + var url = new URL(loc, self.location); + if (isMetadata) { + if (options && options.parts !== undefined) { + url.query.set("parts", options.parts); + } else { + url.query.set("parts", "meta"); + } + } + if (options && options.startLine !== undefined) { + url.query.set("start", options.startLine.toString()); + } + if (options && options.pageSize !== undefined) { + url.query.set("count", options.pageSize.toString()); + } + var timeout = options && options.timeout ? options.timeout : 15000, + opts = { + timeout: timeout, + headers: { + "Orion-Version": "1", + "Accept": "application/json, *.*" + }, + log: false + }; + if (options && typeof options.readIfExists === 'boolean') { + opts.headers["read-if-exists"] = Boolean(options.readIfExists).toString(); + } + return xhr("GET", url.href, opts).then(function(result) { + if (isMetadata) { + var r = result.response ? JSON.parse(result.response) : null; + if (url.query.get("tree") === "compressed") { + expandLocations(r); + } + return r; + } + if (result.xhr.status === 204) { + return null; + } + if (acceptPatch) { + return { + result: result.response, + acceptPatch: result.xhr.getResponseHeader("Accept-Patch") + }; + } + return result.response; + }).then(function(result) { + if (this.makeAbsolute && result) { //can be null on 204 + _normalizeLocations(acceptPatch ? result.result : result); + } + return result; + }.bind(this)); + }, + write: function(location, contents, args) { + var url = new URL(location, window.location); + + var headerData = { + "Orion-Version": "1", + "Content-Type": "text/plain;charset=UTF-8" + }; + if (args && args.ETag) { + headerData["If-Match"] = args.ETag; + } + var options = { + timeout: 15000, + headers: headerData, + data: contents, + log: false + }; + + // check if we have raw contents or something else + var method = "PUT"; + if (typeof contents !== "string") { + // look for remote content + if (contents.sourceLocation) { + options.query = {source: contents.sourceLocation}; + options.data = null; + } else if (contents.diff) { + method = "POST"; + headerData["X-HTTP-Method-Override"] = "PATCH"; + headerData["Content-Type"] = "application/json;charset=UTF-8"; + options.data = JSON.stringify(options.data); + } else { + // assume we are putting metadata + url.query.set("parts", "meta"); + } + } + return xhr(method, url.href, options).then(function(result) { + return result.response ? JSON.parse(result.response) : null; + }).then(function(result) { + if (this.makeAbsolute) { + _normalizeLocations(result); + } + return result; + }.bind(this)); + }, + remoteImport: function(targetLocation, options) { + throw new Error("Not supported"); //$NON-NLS-0$ + }, + remoteExport: function(sourceLocation, options) { + throw new Error("Not supported"); //$NON-NLS-0$ + }, + readBlob: function(location) { + return xhr("GET", location, { //$NON-NLS-0$ + responseType: "arraybuffer", //$NON-NLS-0$ + timeout: GIT_TIMEOUT + }).then(function(result) { + return result.response; + }); + }, + writeBlob: function(location, contents, args) { + throw new Error("Not supported"); //$NON-NLS-0$ + } + }; + CollabFileImpl.prototype.constructor = CollabFileImpl; + + return CollabFileImpl; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabPeer.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabPeer.js new file mode 100644 index 0000000000..ba89ce6792 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabPeer.js @@ -0,0 +1,36 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +define([], function() { + + 'use strict'; + + /** + * A record of a collaborator + * + * @class + * @name orion.collabClient.CollabPeer + * + * @param {string} id - + * @param {string} name - + * @param {string} color - + */ + var CollabPeer = function(id, name, color) { + this.id = id; + this.name = name; + this.color = color; + }; + + return { + CollabPeer: CollabPeer + }; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js new file mode 100644 index 0000000000..47d953cc85 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js @@ -0,0 +1,92 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +/* Initializes websocket connection */ + +define(['orion/EventTarget'], function(EventTarget) { + 'use strict;' + + var DEBUG = false; + + /** + * Collab socket client + * + * @class + * @constructor + * + * @param {string} sessionId + */ + function CollabSocket(hubUrl, sessionId) { + var self = this; + + this.socket = new WebSocket(hubUrl + sessionId); + + this.socket.onopen = function() { + self.dispatchEvent({ + type: 'ready' + }); + }; + + this.socket.onclose = function() { + self.dispatchEvent({ + type: 'close' + }); + }; + + this.socket.onerror = function(e) { + self.dispatchEvent({ + type: 'error', + error: e + }); + console.error(e); + }; + + this.socket.onmessage = function(e) { + self.dispatchEvent({ + type: 'message', + data: e.data + }); + if (DEBUG) { + var msgObj = JSON.parse(e.data); + console.log('CollabSocket In: ' + msgObj.type, msgObj); + } + }; + + EventTarget.attach(this); + } + + CollabSocket.prototype.constructor = CollabSocket; + + /** + * Send message + * + * @param {string} message + */ + CollabSocket.prototype.send = function(message) { + this.socket.send(message); + if (DEBUG) { + var msgObj = JSON.parse(message); + console.log('CollabSocket Out: ' + msgObj.type, msgObj); + } + }; + + /** + * Close this socket + */ + CollabSocket.prototype.close = function() { + this.socket.close(); + }; + + return { + CollabSocket: CollabSocket + }; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/ot.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/ot.js new file mode 100644 index 0000000000..1045f0b0d5 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/ot.js @@ -0,0 +1,1890 @@ +/* + * /\ + * / \ ot 0.0.14 + * / \ http://operational-transformation.github.com + * \ / + * \ / (c) 2012-2014 Tim Baumann (http://timbaumann.info) + * \/ ot may be freely distributed under the MIT license. + */ +define([], function() { + if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; + } + + ot.TextOperation = (function () { + 'use strict'; + + // Constructor for new operations. + function TextOperation () { + if (!this || this.constructor !== TextOperation) { + // => function was called without 'new' + return new TextOperation(); + } + + // When an operation is applied to an input string, you can think of this as + // if an imaginary cursor runs over the entire string and skips over some + // parts, deletes some parts and inserts characters at some positions. These + // actions (skip/delete/insert) are stored as an array in the "ops" property. + this.ops = []; + // An operation's baseLength is the length of every string the operation + // can be applied to. + this.baseLength = 0; + // The targetLength is the length of every string that results from applying + // the operation on a valid input string. + this.targetLength = 0; + } + + TextOperation.prototype.equals = function (other) { + if (this.baseLength !== other.baseLength) { return false; } + if (this.targetLength !== other.targetLength) { return false; } + if (this.ops.length !== other.ops.length) { return false; } + for (var i = 0; i < this.ops.length; i++) { + if (this.ops[i] !== other.ops[i]) { return false; } + } + return true; + }; + + // Operation are essentially lists of ops. There are three types of ops: + // + // * Retain ops: Advance the cursor position by a given number of characters. + // Represented by positive ints. + // * Insert ops: Insert a given string at the current cursor position. + // Represented by strings. + // * Delete ops: Delete the next n characters. Represented by negative ints. + + var isRetain = TextOperation.isRetain = function (op) { + return typeof op === 'number' && op > 0; + }; + + var isInsert = TextOperation.isInsert = function (op) { + return typeof op === 'string'; + }; + + var isDelete = TextOperation.isDelete = function (op) { + return typeof op === 'number' && op < 0; + }; + + + // After an operation is constructed, the user of the library can specify the + // actions of an operation (skip/insert/delete) with these three builder + // methods. They all return the operation for convenient chaining. + + // Skip over a given number of characters. + TextOperation.prototype.retain = function (n) { + if (typeof n !== 'number') { + throw new Error("retain expects an integer"); + } + if (n === 0) { return this; } + this.baseLength += n; + this.targetLength += n; + if (isRetain(this.ops[this.ops.length-1])) { + // The last op is a retain op => we can merge them into one op. + this.ops[this.ops.length-1] += n; + } else { + // Create a new op. + this.ops.push(n); + } + return this; + }; + + // Insert a string at the current position. + TextOperation.prototype.insert = function (str) { + if (typeof str !== 'string') { + throw new Error("insert expects a string"); + } + if (str === '') { return this; } + this.targetLength += str.length; + var ops = this.ops; + if (isInsert(ops[ops.length-1])) { + // Merge insert op. + ops[ops.length-1] += str; + } else if (isDelete(ops[ops.length-1])) { + // It doesn't matter when an operation is applied whether the operation + // is delete(3), insert("something") or insert("something"), delete(3). + // Here we enforce that in this case, the insert op always comes first. + // This makes all operations that have the same effect when applied to + // a document of the right length equal in respect to the `equals` method. + if (isInsert(ops[ops.length-2])) { + ops[ops.length-2] += str; + } else { + ops[ops.length] = ops[ops.length-1]; + ops[ops.length-2] = str; + } + } else { + ops.push(str); + } + return this; + }; + + // Delete a string at the current position. + TextOperation.prototype['delete'] = function (n) { + if (typeof n === 'string') { n = n.length; } + if (typeof n !== 'number') { + throw new Error("delete expects an integer or a string"); + } + if (n === 0) { return this; } + if (n > 0) { n = -n; } + this.baseLength -= n; + if (isDelete(this.ops[this.ops.length-1])) { + this.ops[this.ops.length-1] += n; + } else { + this.ops.push(n); + } + return this; + }; + + // Tests whether this operation has no effect. + TextOperation.prototype.isNoop = function () { + return this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])); + }; + + // Pretty printing. + TextOperation.prototype.toString = function () { + // map: build a new array by applying a function to every element in an old + // array. + var map = Array.prototype.map || function (fn) { + var arr = this; + var newArr = []; + for (var i = 0, l = arr.length; i < l; i++) { + newArr[i] = fn(arr[i]); + } + return newArr; + }; + return map.call(this.ops, function (op) { + if (isRetain(op)) { + return "retain " + op; + } else if (isInsert(op)) { + return "insert '" + op + "'"; + } else { + return "delete " + (-op); + } + }).join(', '); + }; + + // Converts operation into a JSON value. + TextOperation.prototype.toJSON = function () { + return this.ops; + }; + + // Converts a plain JS object into an operation and validates it. + TextOperation.fromJSON = function (ops) { + var o = new TextOperation(); + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + o.retain(op); + } else if (isInsert(op)) { + o.insert(op); + } else if (isDelete(op)) { + o['delete'](op); + } else { + throw new Error("unknown operation: " + JSON.stringify(op)); + } + } + return o; + }; + + // Apply an operation to a string, returning a new string. Throws an error if + // there's a mismatch between the input string and the operation. + TextOperation.prototype.apply = function (str) { + var operation = this; + if (str.length !== operation.baseLength) { + throw new Error("The operation's base length must be equal to the string's length."); + } + var newStr = [], j = 0; + var strIndex = 0; + var ops = this.ops; + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + if (strIndex + op > str.length) { + throw new Error("Operation can't retain more characters than are left in the string."); + } + // Copy skipped part of the old string. + newStr[j++] = str.slice(strIndex, strIndex + op); + strIndex += op; + } else if (isInsert(op)) { + // Insert string. + newStr[j++] = op; + } else { // delete op + strIndex -= op; + } + } + if (strIndex !== str.length) { + throw new Error("The operation didn't operate on the whole string."); + } + return newStr.join(''); + }; + + // Computes the inverse of an operation. The inverse of an operation is the + // operation that reverts the effects of the operation, e.g. when you have an + // operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello "); + // skip(6);'. The inverse should be used for implementing undo. + TextOperation.prototype.invert = function (str) { + var strIndex = 0; + var inverse = new TextOperation(); + var ops = this.ops; + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + inverse.retain(op); + strIndex += op; + } else if (isInsert(op)) { + inverse['delete'](op.length); + } else { // delete op + inverse.insert(str.slice(strIndex, strIndex - op)); + strIndex -= op; + } + } + return inverse; + }; + + // Compose merges two consecutive operations into one operation, that + // preserves the changes of both. Or, in other words, for each input string S + // and a pair of consecutive operations A and B, + // apply(apply(S, A), B) = apply(S, compose(A, B)) must hold. + TextOperation.prototype.compose = function (operation2) { + var operation1 = this; + if (operation1.targetLength !== operation2.baseLength) { + throw new Error("The base length of the second operation has to be the target length of the first operation"); + } + + var operation = new TextOperation(); // the combined operation + var ops1 = operation1.ops, ops2 = operation2.ops; // for fast access + var i1 = 0, i2 = 0; // current index into ops1 respectively ops2 + var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops + while (true) { + // Dispatch on the type of op1 and op2 + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break; + } + + if (isDelete(op1)) { + operation['delete'](op1); + op1 = ops1[i1++]; + continue; + } + if (isInsert(op2)) { + operation.insert(op2); + op2 = ops2[i2++]; + continue; + } + + if (typeof op1 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too short."); + } + if (typeof op2 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too long."); + } + + if (isRetain(op1) && isRetain(op2)) { + if (op1 > op2) { + operation.retain(op2); + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + operation.retain(op1); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation.retain(op1); + op2 = op2 - op1; + op1 = ops1[i1++]; + } + } else if (isInsert(op1) && isDelete(op2)) { + if (op1.length > -op2) { + op1 = op1.slice(-op2); + op2 = ops2[i2++]; + } else if (op1.length === -op2) { + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + op2 = op2 + op1.length; + op1 = ops1[i1++]; + } + } else if (isInsert(op1) && isRetain(op2)) { + if (op1.length > op2) { + operation.insert(op1.slice(0, op2)); + op1 = op1.slice(op2); + op2 = ops2[i2++]; + } else if (op1.length === op2) { + operation.insert(op1); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation.insert(op1); + op2 = op2 - op1.length; + op1 = ops1[i1++]; + } + } else if (isRetain(op1) && isDelete(op2)) { + if (op1 > -op2) { + operation['delete'](op2); + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (op1 === -op2) { + operation['delete'](op2); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation['delete'](op1); + op2 = op2 + op1; + op1 = ops1[i1++]; + } + } else { + throw new Error( + "This shouldn't happen: op1: " + + JSON.stringify(op1) + ", op2: " + + JSON.stringify(op2) + ); + } + } + return operation; + }; + + function getSimpleOp (operation, fn) { + var ops = operation.ops; + var isRetain = TextOperation.isRetain; + switch (ops.length) { + case 1: + return ops[0]; + case 2: + return isRetain(ops[0]) ? ops[1] : (isRetain(ops[1]) ? ops[0] : null); + case 3: + if (isRetain(ops[0]) && isRetain(ops[2])) { return ops[1]; } + } + return null; + } + + function getStartIndex (operation) { + if (isRetain(operation.ops[0])) { return operation.ops[0]; } + return 0; + } + + // When you use ctrl-z to undo your latest changes, you expect the program not + // to undo every single keystroke but to undo your last sentence you wrote at + // a stretch or the deletion you did by holding the backspace key down. This + // This can be implemented by composing operations on the undo stack. This + // method can help decide whether two operations should be composed. It + // returns true if the operations are consecutive insert operations or both + // operations delete text at the same position. You may want to include other + // factors like the time since the last change in your decision. + TextOperation.prototype.shouldBeComposedWith = function (other) { + if (this.isNoop() || other.isNoop()) { return true; } + + var startA = getStartIndex(this), startB = getStartIndex(other); + var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); + if (!simpleA || !simpleB) { return false; } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB; + } + + if (isDelete(simpleA) && isDelete(simpleB)) { + // there are two possibilities to delete: with backspace and with the + // delete key. + return (startB - simpleB === startA) || startA === startB; + } + + return false; + }; + + // Decides whether two operations should be composed with each other + // if they were inverted, that is + // `shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`. + TextOperation.prototype.shouldBeComposedWithInverted = function (other) { + if (this.isNoop() || other.isNoop()) { return true; } + + var startA = getStartIndex(this), startB = getStartIndex(other); + var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); + if (!simpleA || !simpleB) { return false; } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB || startA === startB; + } + + if (isDelete(simpleA) && isDelete(simpleB)) { + return startB - simpleB === startA; + } + + return false; + }; + + // Transform takes two operations A and B that happened concurrently and + // produces two operations A' and B' (in an array) such that + // `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the + // heart of OT. + TextOperation.transform = function (operation1, operation2) { + if (operation1.baseLength !== operation2.baseLength) { + throw new Error("Both operations have to have the same base length"); + } + + var operation1prime = new TextOperation(); + var operation2prime = new TextOperation(); + var ops1 = operation1.ops, ops2 = operation2.ops; + var i1 = 0, i2 = 0; + var op1 = ops1[i1++], op2 = ops2[i2++]; + while (true) { + // At every iteration of the loop, the imaginary cursor that both + // operation1 and operation2 have that operates on the input string must + // have the same position in the input string. + + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break; + } + + // next two cases: one or both ops are insert ops + // => insert the string in the corresponding prime operation, skip it in + // the other one. If both op1 and op2 are insert ops, prefer op1. + if (isInsert(op1)) { + operation1prime.insert(op1); + operation2prime.retain(op1.length); + op1 = ops1[i1++]; + continue; + } + if (isInsert(op2)) { + operation1prime.retain(op2.length); + operation2prime.insert(op2); + op2 = ops2[i2++]; + continue; + } + + if (typeof op1 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too short."); + } + if (typeof op2 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too long."); + } + + var minl; + if (isRetain(op1) && isRetain(op2)) { + // Simple case: retain/retain + if (op1 > op2) { + minl = op2; + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + minl = op2; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = op1; + op2 = op2 - op1; + op1 = ops1[i1++]; + } + operation1prime.retain(minl); + operation2prime.retain(minl); + } else if (isDelete(op1) && isDelete(op2)) { + // Both operations delete the same string at the same position. We don't + // need to produce any operations, we just skip over the delete ops and + // handle the case that one operation deletes more than the other. + if (-op1 > -op2) { + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + op2 = op2 - op1; + op1 = ops1[i1++]; + } + // next two cases: delete/retain and retain/delete + } else if (isDelete(op1) && isRetain(op2)) { + if (-op1 > op2) { + minl = op2; + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (-op1 === op2) { + minl = op2; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = -op1; + op2 = op2 + op1; + op1 = ops1[i1++]; + } + operation1prime['delete'](minl); + } else if (isRetain(op1) && isDelete(op2)) { + if (op1 > -op2) { + minl = -op2; + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (op1 === -op2) { + minl = op1; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = op1; + op2 = op2 + op1; + op1 = ops1[i1++]; + } + operation2prime['delete'](minl); + } else { + throw new Error("The two operations aren't compatible"); + } + } + + return [operation1prime, operation2prime]; + }; + + return TextOperation; + + }()); + + // Export for CommonJS + if (typeof module === 'object') { + module.exports = ot.TextOperation; + } + if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; + } + + ot.Selection = (function (global) { + 'use strict'; + + var TextOperation = global.ot ? global.ot.TextOperation : ot ? ot.TextOperation : require('./text-operation'); + + // Range has `anchor` and `head` properties, which are zero-based indices into + // the document. The `anchor` is the side of the selection that stays fixed, + // `head` is the side of the selection where the cursor is. When both are + // equal, the range represents a cursor. + function Range (anchor, head) { + this.anchor = anchor; + this.head = head; + } + + Range.fromJSON = function (obj) { + return new Range(obj.anchor, obj.head); + }; + + Range.prototype.equals = function (other) { + return this.anchor === other.anchor && this.head === other.head; + }; + + Range.prototype.isEmpty = function () { + return this.anchor === this.head; + }; + + Range.prototype.transform = function (other) { + function transformIndex (index) { + var newIndex = index; + var ops = other.ops; + for (var i = 0, l = other.ops.length; i < l; i++) { + if (TextOperation.isRetain(ops[i])) { + index -= ops[i]; + } else if (TextOperation.isInsert(ops[i])) { + newIndex += ops[i].length; + } else { + newIndex -= Math.min(index, -ops[i]); + index += ops[i]; + } + if (index < 0) { break; } + } + return newIndex; + } + + var newAnchor = transformIndex(this.anchor); + if (this.anchor === this.head) { + return new Range(newAnchor, newAnchor); + } + return new Range(newAnchor, transformIndex(this.head)); + }; + + // A selection is basically an array of ranges. Every range represents a real + // selection or a cursor in the document (when the start position equals the + // end position of the range). The array must not be empty. + function Selection (ranges) { + this.ranges = ranges || []; + } + + Selection.Range = Range; + + // Convenience method for creating selections only containing a single cursor + // and no real selection range. + Selection.createCursor = function (position) { + return new Selection([new Range(position, position)]); + }; + + Selection.fromJSON = function (obj) { + var objRanges = obj.ranges || obj; + for (var i = 0, ranges = []; i < objRanges.length; i++) { + ranges[i] = Range.fromJSON(objRanges[i]); + } + return new Selection(ranges); + }; + + Selection.prototype.equals = function (other) { + if (this.position !== other.position) { return false; } + if (this.ranges.length !== other.ranges.length) { return false; } + // FIXME: Sort ranges before comparing them? + for (var i = 0; i < this.ranges.length; i++) { + if (!this.ranges[i].equals(other.ranges[i])) { return false; } + } + return true; + }; + + Selection.prototype.somethingSelected = function () { + for (var i = 0; i < this.ranges.length; i++) { + if (!this.ranges[i].isEmpty()) { return true; } + } + return false; + }; + + // Return the more current selection information. + Selection.prototype.compose = function (other) { + return other; + }; + + // Update the selection with respect to an operation. + Selection.prototype.transform = function (other) { + for (var i = 0, newRanges = []; i < this.ranges.length; i++) { + newRanges[i] = this.ranges[i].transform(other); + } + return new Selection(newRanges); + }; + + return Selection; + + }(this)); + + // Export for CommonJS + if (typeof module === 'object') { + module.exports = ot.Selection; + } + + if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; + } + + ot.WrappedOperation = (function (global) { + 'use strict'; + + // A WrappedOperation contains an operation and corresponing metadata. + function WrappedOperation (operation, meta) { + this.wrapped = operation; + this.meta = meta; + } + + WrappedOperation.prototype.apply = function () { + return this.wrapped.apply.apply(this.wrapped, arguments); + }; + + WrappedOperation.prototype.invert = function () { + var meta = this.meta; + return new WrappedOperation( + this.wrapped.invert.apply(this.wrapped, arguments), + meta && typeof meta === 'object' && typeof meta.invert === 'function' ? + meta.invert.apply(meta, arguments) : meta + ); + }; + + // Copy all properties from source to target. + function copy (source, target) { + for (var key in source) { + if (source.hasOwnProperty(key)) { + target[key] = source[key]; + } + } + } + + function composeMeta (a, b) { + if (a && typeof a === 'object') { + if (typeof a.compose === 'function') { return a.compose(b); } + var meta = {}; + copy(a, meta); + copy(b, meta); + return meta; + } + return b; + } + + WrappedOperation.prototype.compose = function (other) { + return new WrappedOperation( + this.wrapped.compose(other.wrapped), + composeMeta(this.meta, other.meta) + ); + }; + + function transformMeta (meta, operation) { + if (meta && typeof meta === 'object') { + if (typeof meta.transform === 'function') { + return meta.transform(operation); + } + } + return meta; + } + + WrappedOperation.transform = function (a, b) { + var transform = a.wrapped.constructor.transform; + var pair = transform(a.wrapped, b.wrapped); + return [ + new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)), + new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped)) + ]; + }; + + return WrappedOperation; + + }(this)); + + // Export for CommonJS + if (typeof module === 'object') { + module.exports = ot.WrappedOperation; + } + if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; + } + + ot.UndoManager = (function () { + 'use strict'; + + var NORMAL_STATE = 'normal'; + var UNDOING_STATE = 'undoing'; + var REDOING_STATE = 'redoing'; + + // Create a new UndoManager with an optional maximum history size. + function UndoManager (maxItems) { + this.maxItems = maxItems || 50; + this.state = NORMAL_STATE; + this.dontCompose = false; + this.undoStack = []; + this.redoStack = []; + } + + // Add an operation to the undo or redo stack, depending on the current state + // of the UndoManager. The operation added must be the inverse of the last + // edit. When `compose` is true, compose the operation with the last operation + // unless the last operation was alread pushed on the redo stack or was hidden + // by a newer operation on the undo stack. + UndoManager.prototype.add = function (operation, compose) { + if (this.state === UNDOING_STATE) { + this.redoStack.push(operation); + this.dontCompose = true; + } else if (this.state === REDOING_STATE) { + this.undoStack.push(operation); + this.dontCompose = true; + } else { + var undoStack = this.undoStack; + if (!this.dontCompose && compose && undoStack.length > 0) { + undoStack.push(operation.compose(undoStack.pop())); + } else { + undoStack.push(operation); + if (undoStack.length > this.maxItems) { undoStack.shift(); } + } + this.dontCompose = false; + this.redoStack = []; + } + }; + + function transformStack (stack, operation) { + var newStack = []; + var Operation = operation.constructor; + for (var i = stack.length - 1; i >= 0; i--) { + var pair = Operation.transform(stack[i], operation); + if (typeof pair[0].isNoop !== 'function' || !pair[0].isNoop()) { + newStack.push(pair[0]); + } + operation = pair[1]; + } + return newStack.reverse(); + } + + // Transform the undo and redo stacks against a operation by another client. + UndoManager.prototype.transform = function (operation) { + this.undoStack = transformStack(this.undoStack, operation); + this.redoStack = transformStack(this.redoStack, operation); + }; + + // Perform an undo by calling a function with the latest operation on the undo + // stack. The function is expected to call the `add` method with the inverse + // of the operation, which pushes the inverse on the redo stack. + UndoManager.prototype.performUndo = function (fn) { + this.state = UNDOING_STATE; + if (this.undoStack.length === 0) { throw new Error("undo not possible"); } + fn(this.undoStack.pop()); + this.state = NORMAL_STATE; + }; + + // The inverse of `performUndo`. + UndoManager.prototype.performRedo = function (fn) { + this.state = REDOING_STATE; + if (this.redoStack.length === 0) { throw new Error("redo not possible"); } + fn(this.redoStack.pop()); + this.state = NORMAL_STATE; + }; + + // Is the undo stack not empty? + UndoManager.prototype.canUndo = function () { + return this.undoStack.length !== 0; + }; + + // Is the redo stack not empty? + UndoManager.prototype.canRedo = function () { + return this.redoStack.length !== 0; + }; + + // Whether the UndoManager is currently performing an undo. + UndoManager.prototype.isUndoing = function () { + return this.state === UNDOING_STATE; + }; + + // Whether the UndoManager is currently performing a redo. + UndoManager.prototype.isRedoing = function () { + return this.state === REDOING_STATE; + }; + + return UndoManager; + + }()); + + // Export for CommonJS + if (typeof module === 'object') { + module.exports = ot.UndoManager; + } + + // translation of https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala + + if (typeof ot === 'undefined') { + var ot = {}; + } + + ot.Client = (function (global) { + 'use strict'; + + // Client constructor + function Client (revision) { + this.revision = revision; // the next expected revision number + this.state = synchronized_; // start state + } + + Client.prototype.setState = function (state) { + this.state = state; + }; + + // Call this method when the user changes the document. + Client.prototype.applyClient = function (operation) { + this.setState(this.state.applyClient(this, operation)); + }; + + // Call this method with a new operation from the server + Client.prototype.applyServer = function (operation) { + this.revision++; + this.setState(this.state.applyServer(this, operation)); + }; + + Client.prototype.serverAck = function () { + this.revision++; + this.setState(this.state.serverAck(this)); + }; + + Client.prototype.serverReconnect = function () { + if (typeof this.state.resend === 'function') { this.state.resend(this); } + }; + + // Transforms a selection from the latest known server state to the current + // client state. For example, if we get from the server the information that + // another user's cursor is at position 3, but the server hasn't yet received + // our newest operation, an insertion of 5 characters at the beginning of the + // document, the correct position of the other user's cursor in our current + // document is 8. + Client.prototype.transformSelection = function (selection) { + return this.state.transformSelection(selection); + }; + + // Override this method. + Client.prototype.sendOperation = function (revision, operation) { + throw new Error("sendOperation must be defined in child class"); + }; + + // Override this method. + Client.prototype.applyOperation = function (operation) { + throw new Error("applyOperation must be defined in child class"); + }; + + + // In the 'Synchronized' state, there is no pending operation that the client + // has sent to the server. + function Synchronized () {} + Client.Synchronized = Synchronized; + + Synchronized.prototype.applyClient = function (client, operation) { + // When the user makes an edit, send the operation to the server and + // switch to the 'AwaitingConfirm' state + client.sendOperation(client.revision, operation); + return new AwaitingConfirm(operation); + }; + + Synchronized.prototype.applyServer = function (client, operation) { + // When we receive a new operation from the server, the operation can be + // simply applied to the current document + client.applyOperation(operation); + return this; + }; + + Synchronized.prototype.serverAck = function (client) { + throw new Error("There is no pending operation."); + }; + + // Nothing to do because the latest server state and client state are the same. + Synchronized.prototype.transformSelection = function (x) { return x; }; + + // Singleton + var synchronized_ = new Synchronized(); + + + // In the 'AwaitingConfirm' state, there's one operation the client has sent + // to the server and is still waiting for an acknowledgement. + function AwaitingConfirm (outstanding) { + // Save the pending operation + this.outstanding = outstanding; + } + Client.AwaitingConfirm = AwaitingConfirm; + + AwaitingConfirm.prototype.applyClient = function (client, operation) { + // When the user makes an edit, don't send the operation immediately, + // instead switch to 'AwaitingWithBuffer' state + return new AwaitingWithBuffer(this.outstanding, operation); + }; + + AwaitingConfirm.prototype.applyServer = function (client, operation) { + // This is another client's operation. Visualization: + // + // /\ + // this.outstanding / \ operation + // / \ + // \ / + // pair[1] \ / pair[0] (new outstanding) + // (can be applied \/ + // to the client's + // current document) + var pair = operation.constructor.transform(this.outstanding, operation); + client.applyOperation(pair[1]); + return new AwaitingConfirm(pair[0]); + }; + + AwaitingConfirm.prototype.serverAck = function (client) { + // The client's operation has been acknowledged + // => switch to synchronized state + return synchronized_; + }; + + AwaitingConfirm.prototype.transformSelection = function (selection) { + return selection.transform(this.outstanding); + }; + + AwaitingConfirm.prototype.resend = function (client) { + // The confirm didn't come because the client was disconnected. + // Now that it has reconnected, we resend the outstanding operation. + client.sendOperation(client.revision, this.outstanding); + }; + + + // In the 'AwaitingWithBuffer' state, the client is waiting for an operation + // to be acknowledged by the server while buffering the edits the user makes + function AwaitingWithBuffer (outstanding, buffer) { + // Save the pending operation and the user's edits since then + this.outstanding = outstanding; + this.buffer = buffer; + } + Client.AwaitingWithBuffer = AwaitingWithBuffer; + + AwaitingWithBuffer.prototype.applyClient = function (client, operation) { + // Compose the user's changes onto the buffer + var newBuffer = this.buffer.compose(operation); + return new AwaitingWithBuffer(this.outstanding, newBuffer); + }; + + AwaitingWithBuffer.prototype.applyServer = function (client, operation) { + // Operation comes from another client + // + // /\ + // this.outstanding / \ operation + // / \ + // /\ / + // this.buffer / \* / pair1[0] (new outstanding) + // / \/ + // \ / + // pair2[1] \ / pair2[0] (new buffer) + // the transformed \/ + // operation -- can + // be applied to the + // client's current + // document + // + // * pair1[1] + var transform = operation.constructor.transform; + var pair1 = transform(this.outstanding, operation); + var pair2 = transform(this.buffer, pair1[1]); + client.applyOperation(pair2[1]); + return new AwaitingWithBuffer(pair1[0], pair2[0]); + }; + + AwaitingWithBuffer.prototype.serverAck = function (client) { + // The pending operation has been acknowledged + // => send buffer + client.sendOperation(client.revision, this.buffer); + return new AwaitingConfirm(this.buffer); + }; + + AwaitingWithBuffer.prototype.transformSelection = function (selection) { + return selection.transform(this.outstanding).transform(this.buffer); + }; + + AwaitingWithBuffer.prototype.resend = function (client) { + // The confirm didn't come because the client was disconnected. + // Now that it has reconnected, we resend the outstanding operation. + client.sendOperation(client.revision, this.outstanding); + }; + + + return Client; + + }(this)); + + if (typeof module === 'object') { + module.exports = ot.Client; + } + + /*global ot */ + + ot.CodeMirrorAdapter = (function (global) { + 'use strict'; + + var TextOperation = ot.TextOperation; + var Selection = ot.Selection; + + function CodeMirrorAdapter (cm) { + this.cm = cm; + this.ignoreNextChange = false; + this.changeInProgress = false; + this.selectionChanged = false; + + bind(this, 'onChanges'); + bind(this, 'onChange'); + bind(this, 'onCursorActivity'); + bind(this, 'onFocus'); + bind(this, 'onBlur'); + + cm.on('changes', this.onChanges); + cm.on('change', this.onChange); + cm.on('cursorActivity', this.onCursorActivity); + cm.on('focus', this.onFocus); + cm.on('blur', this.onBlur); + } + + // Removes all event listeners from the CodeMirror instance. + CodeMirrorAdapter.prototype.detach = function () { + this.cm.off('changes', this.onChanges); + this.cm.off('change', this.onChange); + this.cm.off('cursorActivity', this.onCursorActivity); + this.cm.off('focus', this.onFocus); + this.cm.off('blur', this.onBlur); + }; + + function cmpPos (a, b) { + if (a.line < b.line) { return -1; } + if (a.line > b.line) { return 1; } + if (a.ch < b.ch) { return -1; } + if (a.ch > b.ch) { return 1; } + return 0; + } + function posEq (a, b) { return cmpPos(a, b) === 0; } + function posLe (a, b) { return cmpPos(a, b) <= 0; } + + function minPos (a, b) { return posLe(a, b) ? a : b; } + function maxPos (a, b) { return posLe(a, b) ? b : a; } + + function codemirrorDocLength (doc) { + return doc.indexFromPos({ line: doc.lastLine(), ch: 0 }) + + doc.getLine(doc.lastLine()).length; + } + + // Converts a CodeMirror change array (as obtained from the 'changes' event + // in CodeMirror v4) or single change or linked list of changes (as returned + // by the 'change' event in CodeMirror prior to version 4) into a + // TextOperation and its inverse and returns them as a two-element array. + CodeMirrorAdapter.operationFromCodeMirrorChanges = function (changes, doc) { + // Approach: Replay the changes, beginning with the most recent one, and + // construct the operation and its inverse. We have to convert the position + // in the pre-change coordinate system to an index. We have a method to + // convert a position in the coordinate system after all changes to an index, + // namely CodeMirror's `indexFromPos` method. We can use the information of + // a single change object to convert a post-change coordinate system to a + // pre-change coordinate system. We can now proceed inductively to get a + // pre-change coordinate system for all changes in the linked list. + // A disadvantage of this approach is its complexity `O(n^2)` in the length + // of the linked list of changes. + + var docEndLength = codemirrorDocLength(doc); + var operation = new TextOperation().retain(docEndLength); + var inverse = new TextOperation().retain(docEndLength); + + var indexFromPos = function (pos) { + return doc.indexFromPos(pos); + }; + + function last (arr) { return arr[arr.length - 1]; } + + function sumLengths (strArr) { + if (strArr.length === 0) { return 0; } + var sum = 0; + for (var i = 0; i < strArr.length; i++) { sum += strArr[i].length; } + return sum + strArr.length - 1; + } + + function updateIndexFromPos (indexFromPos, change) { + return function (pos) { + if (posLe(pos, change.from)) { return indexFromPos(pos); } + if (posLe(change.to, pos)) { + return indexFromPos({ + line: pos.line + change.text.length - 1 - (change.to.line - change.from.line), + ch: (change.to.line < pos.line) ? + pos.ch : + (change.text.length <= 1) ? + pos.ch - (change.to.ch - change.from.ch) + sumLengths(change.text) : + pos.ch - change.to.ch + last(change.text).length + }) + sumLengths(change.removed) - sumLengths(change.text); + } + if (change.from.line === pos.line) { + return indexFromPos(change.from) + pos.ch - change.from.ch; + } + return indexFromPos(change.from) + + sumLengths(change.removed.slice(0, pos.line - change.from.line)) + + 1 + pos.ch; + }; + } + + for (var i = changes.length - 1; i >= 0; i--) { + var change = changes[i]; + indexFromPos = updateIndexFromPos(indexFromPos, change); + + var fromIndex = indexFromPos(change.from); + var restLength = docEndLength - fromIndex - sumLengths(change.text); + + operation = new TextOperation() + .retain(fromIndex) + ['delete'](sumLengths(change.removed)) + .insert(change.text.join('\n')) + .retain(restLength) + .compose(operation); + + inverse = inverse.compose(new TextOperation() + .retain(fromIndex) + ['delete'](sumLengths(change.text)) + .insert(change.removed.join('\n')) + .retain(restLength) + ); + + docEndLength += sumLengths(change.removed) - sumLengths(change.text); + } + + return [operation, inverse]; + }; + + // Singular form for backwards compatibility. + CodeMirrorAdapter.operationFromCodeMirrorChange = + CodeMirrorAdapter.operationFromCodeMirrorChanges; + + // Apply an operation to a CodeMirror instance. + CodeMirrorAdapter.applyOperationToCodeMirror = function (operation, cm) { + cm.operation(function () { + var ops = operation.ops; + var index = 0; // holds the current index into CodeMirror's content + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (TextOperation.isRetain(op)) { + index += op; + } else if (TextOperation.isInsert(op)) { + cm.replaceRange(op, cm.posFromIndex(index)); + index += op.length; + } else if (TextOperation.isDelete(op)) { + var from = cm.posFromIndex(index); + var to = cm.posFromIndex(index - op); + cm.replaceRange('', from, to); + } + } + }); + }; + + CodeMirrorAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + CodeMirrorAdapter.prototype.onChange = function () { + // By default, CodeMirror's event order is the following: + // 1. 'change', 2. 'cursorActivity', 3. 'changes'. + // We want to fire the 'selectionChange' event after the 'change' event, + // but need the information from the 'changes' event. Therefore, we detect + // when a change is in progress by listening to the change event, setting + // a flag that makes this adapter defer all 'cursorActivity' events. + this.changeInProgress = true; + }; + + CodeMirrorAdapter.prototype.onChanges = function (_, changes) { + if (!this.ignoreNextChange) { + var pair = CodeMirrorAdapter.operationFromCodeMirrorChanges(changes, this.cm); + this.trigger('change', pair[0], pair[1]); + } + if (this.selectionChanged) { this.trigger('selectionChange'); } + this.changeInProgress = false; + this.ignoreNextChange = false; + }; + + CodeMirrorAdapter.prototype.onCursorActivity = + CodeMirrorAdapter.prototype.onFocus = function () { + if (this.changeInProgress) { + this.selectionChanged = true; + } else { + this.trigger('selectionChange'); + } + }; + + CodeMirrorAdapter.prototype.onBlur = function () { + if (!this.cm.somethingSelected()) { this.trigger('blur'); } + }; + + CodeMirrorAdapter.prototype.getValue = function () { + return this.cm.getValue(); + }; + + CodeMirrorAdapter.prototype.getSelection = function () { + var cm = this.cm; + + var selectionList = cm.listSelections(); + var ranges = []; + for (var i = 0; i < selectionList.length; i++) { + ranges[i] = new Selection.Range( + cm.indexFromPos(selectionList[i].anchor), + cm.indexFromPos(selectionList[i].head) + ); + } + + return new Selection(ranges); + }; + + CodeMirrorAdapter.prototype.setSelection = function (selection) { + var ranges = []; + for (var i = 0; i < selection.ranges.length; i++) { + var range = selection.ranges[i]; + ranges[i] = { + anchor: this.cm.posFromIndex(range.anchor), + head: this.cm.posFromIndex(range.head) + }; + } + this.cm.setSelections(ranges); + }; + + var addStyleRule = (function () { + var added = {}; + var styleElement = document.createElement('style'); + document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement); + var styleSheet = styleElement.sheet; + + return function (css) { + if (added[css]) { return; } + added[css] = true; + styleSheet.insertRule(css, (styleSheet.cssRules || styleSheet.rules).length); + }; + }()); + + CodeMirrorAdapter.prototype.setOtherCursor = function (position, color, clientId) { + var cursorPos = this.cm.posFromIndex(position); + var cursorCoords = this.cm.cursorCoords(cursorPos); + var cursorEl = document.createElement('span'); + cursorEl.className = 'other-client'; + cursorEl.style.display = 'inline-block'; + cursorEl.style.padding = '0'; + cursorEl.style.marginLeft = cursorEl.style.marginRight = '-1px'; + cursorEl.style.borderLeftWidth = '2px'; + cursorEl.style.borderLeftStyle = 'solid'; + cursorEl.style.borderLeftColor = color; + cursorEl.style.height = (cursorCoords.bottom - cursorCoords.top) * 0.9 + 'px'; + cursorEl.style.zIndex = 0; + cursorEl.setAttribute('data-clientid', clientId); + return this.cm.setBookmark(cursorPos, { widget: cursorEl, insertLeft: true }); + }; + + CodeMirrorAdapter.prototype.setOtherSelectionRange = function (range, color, clientId) { + var match = /^#([0-9a-fA-F]{6})$/.exec(color); + if (!match) { throw new Error("only six-digit hex colors are allowed."); } + var selectionClassName = 'selection-' + match[1]; + var rule = '.' + selectionClassName + ' { background: ' + color + '; }'; + addStyleRule(rule); + + var anchorPos = this.cm.posFromIndex(range.anchor); + var headPos = this.cm.posFromIndex(range.head); + + return this.cm.markText( + minPos(anchorPos, headPos), + maxPos(anchorPos, headPos), + { className: selectionClassName } + ); + }; + + CodeMirrorAdapter.prototype.setOtherSelection = function (selection, color, clientId) { + var selectionObjects = []; + for (var i = 0; i < selection.ranges.length; i++) { + var range = selection.ranges[i]; + if (range.isEmpty()) { + selectionObjects[i] = this.setOtherCursor(range.head, color, clientId); + } else { + selectionObjects[i] = this.setOtherSelectionRange(range, color, clientId); + } + } + return { + clear: function () { + for (var i = 0; i < selectionObjects.length; i++) { + selectionObjects[i].clear(); + } + } + }; + }; + + CodeMirrorAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { action.apply(this, args); } + }; + + CodeMirrorAdapter.prototype.applyOperation = function (operation) { + this.ignoreNextChange = true; + CodeMirrorAdapter.applyOperationToCodeMirror(operation, this.cm); + }; + + CodeMirrorAdapter.prototype.registerUndo = function (undoFn) { + this.cm.undo = undoFn; + }; + + CodeMirrorAdapter.prototype.registerRedo = function (redoFn) { + this.cm.redo = redoFn; + }; + + // Throws an error if the first argument is falsy. Useful for debugging. + function assert (b, msg) { + if (!b) { + throw new Error(msg || "assertion error"); + } + } + + // Bind a method to an object, so it doesn't matter whether you call + // object.method() directly or pass object.method as a reference to another + // function. + function bind (obj, method) { + var fn = obj[method]; + obj[method] = function () { + fn.apply(obj, arguments); + }; + } + + return CodeMirrorAdapter; + + }(this)); + + /*global ot */ + + ot.SocketIOAdapter = (function () { + 'use strict'; + + function SocketIOAdapter (socket) { + this.socket = socket; + + var self = this; + socket + .on('client_left', function (clientId) { + self.trigger('client_left', clientId); + }) + .on('set_name', function (clientId, name) { + self.trigger('set_name', clientId, name); + }) + .on('ack', function () { self.trigger('ack'); }) + .on('operation', function (clientId, operation, selection) { + self.trigger('operation', operation); + self.trigger('selection', clientId, selection); + }) + .on('selection', function (clientId, selection) { + self.trigger('selection', clientId, selection); + }) + .on('reconnect', function () { + self.trigger('reconnect'); + }); + } + + SocketIOAdapter.prototype.sendOperation = function (revision, operation, selection) { + this.socket.emit('operation', revision, operation, selection); + }; + + SocketIOAdapter.prototype.sendSelection = function (selection) { + this.socket.emit('selection', selection); + }; + + SocketIOAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + SocketIOAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { action.apply(this, args); } + }; + + return SocketIOAdapter; + + }()); + /*global ot, $ */ + + ot.AjaxAdapter = (function () { + 'use strict'; + + function AjaxAdapter (path, ownUserName, revision) { + if (path[path.length - 1] !== '/') { path += '/'; } + this.path = path; + this.ownUserName = ownUserName; + this.majorRevision = revision.major || 0; + this.minorRevision = revision.minor || 0; + this.poll(); + } + + AjaxAdapter.prototype.renderRevisionPath = function () { + return 'revision/' + this.majorRevision + '-' + this.minorRevision; + }; + + AjaxAdapter.prototype.handleResponse = function (data) { + var i; + var operations = data.operations; + for (i = 0; i < operations.length; i++) { + if (operations[i].user === this.ownUserName) { + this.trigger('ack'); + } else { + this.trigger('operation', operations[i].operation); + } + } + if (operations.length > 0) { + this.majorRevision += operations.length; + this.minorRevision = 0; + } + + var events = data.events; + if (events) { + for (i = 0; i < events.length; i++) { + var user = events[i].user; + if (user === this.ownUserName) { continue; } + switch (events[i].event) { + case 'joined': this.trigger('set_name', user, user); break; + case 'left': this.trigger('client_left', user); break; + case 'selection': this.trigger('selection', user, events[i].selection); break; + } + } + this.minorRevision += events.length; + } + + var users = data.users; + if (users) { + delete users[this.ownUserName]; + this.trigger('clients', users); + } + + if (data.revision) { + this.majorRevision = data.revision.major; + this.minorRevision = data.revision.minor; + } + }; + + AjaxAdapter.prototype.poll = function () { + var self = this; + $.ajax({ + url: this.path + this.renderRevisionPath(), + type: 'GET', + dataType: 'json', + timeout: 5000, + success: function (data) { + self.handleResponse(data); + self.poll(); + }, + error: function () { + setTimeout(function () { self.poll(); }, 500); + } + }); + }; + + AjaxAdapter.prototype.sendOperation = function (revision, operation, selection) { + if (revision !== this.majorRevision) { throw new Error("Revision numbers out of sync"); } + var self = this; + $.ajax({ + url: this.path + this.renderRevisionPath(), + type: 'POST', + data: JSON.stringify({ operation: operation, selection: selection }), + contentType: 'application/json', + processData: false, + success: function (data) {}, + error: function () { + setTimeout(function () { self.sendOperation(revision, operation, selection); }, 500); + } + }); + }; + + AjaxAdapter.prototype.sendSelection = function (obj) { + $.ajax({ + url: this.path + this.renderRevisionPath() + '/selection', + type: 'POST', + data: JSON.stringify(obj), + contentType: 'application/json', + processData: false, + timeout: 1000 + }); + }; + + AjaxAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + AjaxAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { action.apply(this, args); } + }; + + return AjaxAdapter; + + })(); + /*global ot */ + + ot.EditorClient = (function () { + 'use strict'; + + var Client = ot.Client; + var Selection = ot.Selection; + var UndoManager = ot.UndoManager; + var TextOperation = ot.TextOperation; + var WrappedOperation = ot.WrappedOperation; + + + function SelfMeta (selectionBefore, selectionAfter) { + this.selectionBefore = selectionBefore; + this.selectionAfter = selectionAfter; + } + + SelfMeta.prototype.invert = function () { + return new SelfMeta(this.selectionAfter, this.selectionBefore); + }; + + SelfMeta.prototype.compose = function (other) { + return new SelfMeta(this.selectionBefore, other.selectionAfter); + }; + + SelfMeta.prototype.transform = function (operation) { + return new SelfMeta( + this.selectionBefore.transform(operation), + this.selectionAfter.transform(operation) + ); + }; + + + function OtherMeta (clientId, selection) { + this.clientId = clientId; + this.selection = selection; + } + + OtherMeta.fromJSON = function (obj) { + return new OtherMeta( + obj.clientId, + obj.selection && Selection.fromJSON(obj.selection) + ); + }; + + OtherMeta.prototype.transform = function (operation) { + return new OtherMeta( + this.clientId, + this.selection && this.selection.transform(operation) + ); + }; + + + function OtherClient (id, listEl, editorAdapter, name, selection) { + this.id = id; + this.listEl = listEl; + this.editorAdapter = editorAdapter; + this.name = name; + + this.li = document.createElement('li'); + if (name) { + this.li.textContent = name; + this.listEl.appendChild(this.li); + } + + this.setColor(name ? hueFromName(name) : Math.random()); + if (selection) { this.updateSelection(selection); } + } + + OtherClient.prototype.setColor = function (hue) { + this.hue = hue; + this.color = hsl2hex(hue, 0.75, 0.5); + this.lightColor = hsl2hex(hue, 0.5, 0.9); + if (this.li) { this.li.style.color = this.color; } + }; + + OtherClient.prototype.setName = function (name) { + if (this.name === name) { return; } + this.name = name; + + this.li.textContent = name; + if (!this.li.parentNode) { + this.listEl.appendChild(this.li); + } + + this.setColor(hueFromName(name)); + }; + + OtherClient.prototype.updateSelection = function (selection) { + this.removeSelection(); + this.selection = selection; + this.mark = this.editorAdapter.setOtherSelection( + selection, + selection.position === selection.selectionEnd ? this.color : this.lightColor, + this.id + ); + }; + + OtherClient.prototype.remove = function () { + if (this.li) { removeElement(this.li); } + this.removeSelection(); + }; + + OtherClient.prototype.removeSelection = function () { + if (this.mark) { + this.mark.clear(); + this.mark = null; + } + }; + + + function EditorClient (revision, clients, serverAdapter, editorAdapter) { + Client.call(this, revision); + this.serverAdapter = serverAdapter; + this.editorAdapter = editorAdapter; + this.undoManager = new UndoManager(); + + this.initializeClientList(); + this.initializeClients(clients); + + var self = this; + + this.editorAdapter.registerCallbacks({ + change: function (operation, inverse) { self.onChange(operation, inverse); }, + selectionChange: function () { self.onSelectionChange(); }, + blur: function () { self.onBlur(); } + }); + this.editorAdapter.registerUndo(function () { self.undo(); }); + this.editorAdapter.registerRedo(function () { self.redo(); }); + + this.serverAdapter.registerCallbacks({ + client_left: function (clientId) { self.onClientLeft(clientId); }, + set_name: function (clientId, name) { self.getClientObject(clientId).setName(name); }, + ack: function () { self.serverAck(); }, + operation: function (operation) { + self.applyServer(TextOperation.fromJSON(operation)); + }, + selection: function (clientId, selection) { + if (selection) { + self.getClientObject(clientId).updateSelection( + self.transformSelection(Selection.fromJSON(selection)) + ); + } else { + self.getClientObject(clientId).removeSelection(); + } + }, + clients: function (clients) { + var clientId; + for (clientId in self.clients) { + if (self.clients.hasOwnProperty(clientId) && !clients.hasOwnProperty(clientId)) { + self.onClientLeft(clientId); + } + } + + for (clientId in clients) { + if (clients.hasOwnProperty(clientId)) { + var clientObject = self.getClientObject(clientId); + + if (clients[clientId].name) { + clientObject.setName(clients[clientId].name); + } + + var selection = clients[clientId].selection; + if (selection) { + self.clients[clientId].updateSelection( + self.transformSelection(Selection.fromJSON(selection)) + ); + } else { + self.clients[clientId].removeSelection(); + } + } + } + }, + reconnect: function () { self.serverReconnect(); } + }); + } + + inherit(EditorClient, Client); + + EditorClient.prototype.addClient = function (clientId, clientObj) { + this.clients[clientId] = new OtherClient( + clientId, + this.clientListEl, + this.editorAdapter, + clientObj.name || clientId, + clientObj.selection ? Selection.fromJSON(clientObj.selection) : null + ); + }; + + EditorClient.prototype.initializeClients = function (clients) { + this.clients = {}; + for (var clientId in clients) { + if (clients.hasOwnProperty(clientId)) { + this.addClient(clientId, clients[clientId]); + } + } + }; + + EditorClient.prototype.getClientObject = function (clientId) { + var client = this.clients[clientId]; + if (client) { return client; } + return this.clients[clientId] = new OtherClient( + clientId, + this.clientListEl, + this.editorAdapter + ); + }; + + EditorClient.prototype.onClientLeft = function (clientId) { + console.log("User disconnected: " + clientId); + var client = this.clients[clientId]; + if (!client) { return; } + client.remove(); + delete this.clients[clientId]; + }; + + EditorClient.prototype.initializeClientList = function () { + this.clientListEl = document.createElement('ul'); + }; + + EditorClient.prototype.applyUnredo = function (operation) { + this.undoManager.add(operation.invert(this.editorAdapter.getValue())); + this.editorAdapter.applyOperation(operation.wrapped); + this.selection = operation.meta.selectionAfter; + this.editorAdapter.setSelection(this.selection); + this.applyClient(operation.wrapped); + }; + + EditorClient.prototype.undo = function () { + var self = this; + if (!this.undoManager.canUndo()) { return; } + this.undoManager.performUndo(function (o) { self.applyUnredo(o); }); + }; + + EditorClient.prototype.redo = function () { + var self = this; + if (!this.undoManager.canRedo()) { return; } + this.undoManager.performRedo(function (o) { self.applyUnredo(o); }); + }; + + EditorClient.prototype.onChange = function (textOperation, inverse) { + var selectionBefore = this.selection; + this.updateSelection(); + var meta = new SelfMeta(selectionBefore, this.selection); + var operation = new WrappedOperation(textOperation, meta); + + var compose = this.undoManager.undoStack.length > 0 && + inverse.shouldBeComposedWithInverted(last(this.undoManager.undoStack).wrapped); + var inverseMeta = new SelfMeta(this.selection, selectionBefore); + this.undoManager.add(new WrappedOperation(inverse, inverseMeta), compose); + this.applyClient(textOperation); + }; + + EditorClient.prototype.updateSelection = function () { + this.selection = this.editorAdapter.getSelection(); + }; + + EditorClient.prototype.onSelectionChange = function () { + var oldSelection = this.selection; + this.updateSelection(); + if (oldSelection && this.selection.equals(oldSelection)) { return; } + this.sendSelection(this.selection); + }; + + EditorClient.prototype.onBlur = function () { + this.selection = null; + this.sendSelection(null); + }; + + EditorClient.prototype.sendSelection = function (selection) { + if (this.state instanceof Client.AwaitingWithBuffer) { return; } + this.serverAdapter.sendSelection(selection); + }; + + EditorClient.prototype.sendOperation = function (revision, operation) { + this.serverAdapter.sendOperation(revision, operation.toJSON(), this.selection); + }; + + EditorClient.prototype.applyOperation = function (operation) { + this.editorAdapter.applyOperation(operation); + this.updateSelection(); + this.undoManager.transform(new WrappedOperation(operation, null)); + }; + + function rgb2hex (r, g, b) { + function digits (n) { + var m = Math.round(255*n).toString(16); + return m.length === 1 ? '0'+m : m; + } + return '#' + digits(r) + digits(g) + digits(b); + } + + function hsl2hex (h, s, l) { + if (s === 0) { return rgb2hex(l, l, l); } + var var2 = l < 0.5 ? l * (1+s) : (l+s) - (s*l); + var var1 = 2 * l - var2; + var hue2rgb = function (hue) { + if (hue < 0) { hue += 1; } + if (hue > 1) { hue -= 1; } + if (6*hue < 1) { return var1 + (var2-var1)*6*hue; } + if (2*hue < 1) { return var2; } + if (3*hue < 2) { return var1 + (var2-var1)*6*(2/3 - hue); } + return var1; + }; + return rgb2hex(hue2rgb(h+1/3), hue2rgb(h), hue2rgb(h-1/3)); + } + + function hueFromName (name) { + var a = 1; + for (var i = 0; i < name.length; i++) { + a = 17 * (a+name.charCodeAt(i)) % 360; + } + return a/360; + } + + // Set Const.prototype.__proto__ to Super.prototype + function inherit (Const, Super) { + function F () {} + F.prototype = Super.prototype; + Const.prototype = new F(); + Const.prototype.constructor = Const; + } + + function last (arr) { return arr[arr.length - 1]; } + + // Remove an element from the DOM. + function removeElement (el) { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + } + + return EditorClient; + }()); + + return ot; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js new file mode 100644 index 0000000000..40b38f1178 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js @@ -0,0 +1,750 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd */ +define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function(mCollabPeer, ot, mUIUtils) { + + 'use strict'; + + var CollabPeer = mCollabPeer.CollabPeer; + var TextOperation = ot.TextOperation; + var Selection = ot.Selection; + + var INPUT_CHANGED_EVENT_INTERVAL = 250; + + var EDITING_FLAG_DURATION = 1000; + + var contextPath = location.pathname.substr(0, location.pathname.lastIndexOf('/edit/edit.html')); + + /** + * The abstract socket adapter for OT + * + * @abstract + * @class + * @name orion.collab.OrionSocketAdapter + * + * @param {orion.collabClient.CollabClient} collabClient + */ + var OrionSocketAdapter = function(collabClient) { + this.collabClient = collabClient; + this.callbacks = []; + this.ignoreNextOperation = false; + }; + + OrionSocketAdapter.prototype.constructor = OrionSocketAdapter; + + /** + * Send authenticate message + */ + OrionSocketAdapter.prototype.authenticate = function() { + var msg = { + 'type': 'authenticate', + 'token': JSON.parse(localStorage.getItem('orion.user')).jwt, + 'clientId': this.collabClient.getClientId() + }; + this.send(JSON.stringify(msg)); + }; + + /** + * Send text + * + * Implement this. + * + * @param {string} text + */ + OrionSocketAdapter.prototype.send = function(text) { + throw new Error('Not implemented.'); + }; + + /** + * Message handler + * + * @param {Object} msg + */ + OrionSocketAdapter.prototype._onMessage = function(msg) { + if (msg.doc) { + this._onDocMessage(msg); + } else { + this._onSessionMessage(msg); + } + }; + + /** + * Document Message handler + * + * @param {Object} msg + */ + OrionSocketAdapter.prototype._onDocMessage = function(msg) { + if (msg.doc !== this.collabClient.currentDoc() || !this.collabClient.textView) { + return; + } + switch(msg.type) { + case "init-document": + this.collabClient.startOT(msg.revision, msg.operation, msg.clients); + this.collabClient.awaitingClients = false; + break; + case "ack": + this.trigger('ack'); + break; + case "operation": + this.ignoreNextOperation = true; + try { + this.trigger('operation', msg.operation); + } catch (ex) { + this.sendInit(); + } finally { + this.ignoreNextOperation = false; + } + this.collabClient.editor.markClean(); + break; + case "selection": + this.trigger('selection', msg.clientId, msg.selection); + break; + case "reconnect": + this.trigger('reconnect'); + break; + } + }; + + /** + * Session Message handler + * + * @param {Object} msg + */ + OrionSocketAdapter.prototype._onSessionMessage = function(msg) { + var type = msg.type; + switch (type) { + case 'file-operation': + this.collabClient.handleFileOperation(msg); + break; + } + }; + + /** + * Send the initial message + */ + OrionSocketAdapter.prototype.sendInit = function() { + var msg = { + 'type': 'join-document', + 'doc': this.collabClient.currentDoc(), + 'clientId': this.collabClient.getClientId() + }; + + this.send(JSON.stringify(msg)); + } + + /** + * Send OT operation + * @param {number} revision + * @param {OT.Operation} operation + * @param {OT.Selection} selection + */ + OrionSocketAdapter.prototype.sendOperation = function(revision, operation, selection) { + if (this.ignoreNextOperation) { + return; + } + var myDoc = this.collabClient.currentDoc(); + var msg = { + 'type': 'operation', + 'revision': revision, + 'operation': operation, + 'selection': selection, + 'doc': myDoc, + 'clientId': this.collabClient.getClientId() + }; + this.send(JSON.stringify(msg)); + this.collabClient.editor.markClean(); + }; + + /** + * Send OT selection + * @param {OT.Selection} selection + */ + OrionSocketAdapter.prototype.sendSelection = function (selection) { + var myDoc = this.collabClient.currentDoc(); + var msg = { + 'type': 'selection', + 'selection': selection, + 'doc': myDoc, + 'clientId': this.collabClient.getClientId() + }; + this.send(JSON.stringify(msg)); + }; + + /** + * Register callbacks. + * We won't use EventTarget because OT uses registerCallbacks/trigger to + * perform event operations. + * + * @param {Object.)>} cb - callbacks + */ + OrionSocketAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + /** + * Trigger an event. + * + * @param {Object} event + */ + OrionSocketAdapter.prototype.trigger = function (event) { + if (!this.collabClient.textView) return; + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { action.apply(this, args); } + }; + + /** + * The socket adapter for OT using CollabSocket as the communitation socket + * + * @class + * @extends orion.collab.OrionSocketAdapter + * @name orion.collab.OrionCollabSocketAdapter + * + * @param {orion.collabClient.CollabClient} client + * @param {orion.collabClient.CollabSocket} socket + */ + var OrionCollabSocketAdapter = function(client, socket) { + OrionSocketAdapter.call(this, client); + + var self = this; + this.socket = socket; + this.authenticated = false; + + // Register incoming message handler + this.socket.addEventListener('message', function(e) { + self._onMessage(JSON.parse(e.data)); + }); + }; + + OrionCollabSocketAdapter.prototype = Object.create(OrionSocketAdapter.prototype); + OrionCollabSocketAdapter.prototype.constructor = OrionCollabSocketAdapter; + + /** + * Send text + * + * @param {string} text + */ + OrionCollabSocketAdapter.prototype.send = function(text) { + this.socket.send(text); + }; + + /** + * Session Message handler + * + * @param {Object} msg + */ + OrionCollabSocketAdapter.prototype._onDocMessage = function(msg) { + switch (msg.type) { + case 'client-joined-doc': + // Add new client to OT + this.trigger('client_joined', msg.clientId, this.collabClient.getPeer(msg.clientId)); + break; + + case 'client-left-doc': + // Clear this client's line annotation + this.trigger('client_left', msg.clientId); + break; + + default: + OrionSocketAdapter.prototype._onDocMessage.call(this, msg); + break; + } + }; + + /** + * Session Message handler + * + * @param {Object} msg + */ + OrionCollabSocketAdapter.prototype._onSessionMessage = function(msg) { + switch(msg.type) { + case 'authenticated': + // Do some initialization + this.authenticated = true; + this.collabClient.sendCurrentLocation(); + this.updateClient({ + name: this.collabClient.getClientDisplayedName() + }); + this.send(JSON.stringify({ + type: 'get-clients' + })); + if (this.collabClient.textView) { + this.collabClient.viewInstalled(); + } + break; + + case 'client-joined': + case 'client-updated': + this.collabClient.addOrUpdatePeer(new CollabPeer(msg.clientId, msg.name, msg.color)); + if (msg.location) { + this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + '/file/' + msg.location, msg.editing); + } else { + this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, '', msg.editing); + } + break; + + case 'client-left': + this.collabClient.removePeer(msg.clientId); + break; + + default: + OrionSocketAdapter.prototype._onSessionMessage.call(this, msg); + break; + } + }; + + /** + * Send current location to the server + * + * @param {string} location + */ + OrionCollabSocketAdapter.prototype.sendLocation = function(location) { + this.send(JSON.stringify({ + type: 'update-client', + location: location, + editing: this.collabClient.editing + })); + }; + + /** + * Update this client's info to the server + * + * @param {Object} clientData - fields to update + */ + OrionCollabSocketAdapter.prototype.updateClient = function(clientData) { + clientData.type = 'update-client'; + this.send(JSON.stringify(clientData)); + }; + + var OrionEditorAdapter = function (editor, collabClient, annotationTypes) { + this.editor = editor; + this.orion = editor.getTextView(); + this.model = editor.getModel(); + this.ignoreNextChange = false; + this.changeInProgress = false; + this.selectionChanged = false; + this.myLine = 0; + this.deleteContent = ""; + this.AT = annotationTypes; + this.annotations = {}; + this.collabClient = collabClient; + this.inputChangedRequested = false; + this.collabClient.editing = false; + this.editingTimeout = 0; + + this.destroyCollabAnnotations(); + + this._onChanging = this.onChanging.bind(this); + this._onChanged = this.onChanged.bind(this); + this._onCursorActivity = this.onCursorActivity.bind(this); + this._onFocus = this.onFocus.bind(this); + this._onBlur = this.onBlur.bind(this); + this._selectionListener = this.selectionListener.bind(this); + + this.model.addEventListener('Changing', this._onChanging); + this.model.addEventListener('Changed', this._onChanged); + this.orion.addEventListener('cursorActivity', this._onCursorActivity); + this.orion.addEventListener('focus', this._onFocus); + this.orion.addEventListener('blur', this._onBlur); + this.orion.addEventListener('Selection', this._selectionListener); + } + + // Removes all event listeners from the Orion instance. + OrionEditorAdapter.prototype.detach = function () { + this.model.removeEventListener('Changing', this._onChanging); + this.model.removeEventListener('Changed', this._onChanged); + this.orion.removeEventListener('cursorActivity', this._onCursorActivity); + this.orion.removeEventListener('focus', this._onFocus); + this.orion.removeEventListener('blur', this._onBlur); + this.orion.removeEventListener('Selection', this._selectionListener); + }; + + // Converts a Orion change array (as obtained from the 'changes' event + // in Orion v4) or single change or linked list of changes (as returned + // by the 'change' event in Orion prior to version 4) into a + // TextOperation and its inverse and returns them as a two-element array. + OrionEditorAdapter.prototype.operationFromOrionChanges = function (changes, doc, deletedText) { + // Approach: Replay the changes, beginning with the most recent one, and + // construct the operation and its inverse. We have to convert the position + // in the pre-change coordinate system to an index. We have a method to + // convert a position in the coordinate system after all changes to an index, + // namely Orion's `indexFromPos` method. We can use the information of + // a single change object to convert a post-change coordinate system to a + // pre-change coordinate system. We can now proceed inductively to get a + // pre-change coordinate system for all changes in the linked list. + // A disadvantage of this approach is its complexity `O(n^2)` in the length + // of the linked list of changes. + + var docEndLength = this.model.getCharCount() - changes[0].addedCharCount + changes[0].removedCharCount; + var operation = new TextOperation().retain(docEndLength); + var inverse = new TextOperation().retain(docEndLength); + + for (var i = changes.length - 1; i >= 0; i--) { + var change = changes[i]; + + var fromIndex = change.start; + var restLength = docEndLength - fromIndex - change.removedCharCount; + + operation = operation.compose(new TextOperation() + .retain(fromIndex) + ['delete'](change.removedCharCount) + .insert(change.text) + .retain(restLength) + ); + + if (change.addedCharCount && change.removedCharCount) { + //REPLACE ACTION + inverse = new TextOperation() + .retain(fromIndex) + ['delete'](change.addedCharCount) + .insert(deletedText) + .retain(restLength) + .compose(inverse); + } else if (change.addedCharCount) { + //INSERT ACTION + inverse = new TextOperation() + .retain(fromIndex) + ['delete'](change.addedCharCount) + .retain(restLength) + .compose(inverse); + } else { + //DELETE ACTION + inverse = new TextOperation() + .retain(fromIndex) + .insert(deletedText) + .retain(restLength) + .compose(inverse); + } + + docEndLength += change.removedCharCount - change.text.length; + } + + return [operation, inverse]; + }; + + /** + * Apply an operation to a Orion instance. + * + * @throws {Error} operation bound check failed + * + * @param {ot.Operation} operation - + * @param {TextView} orion - + */ + OrionEditorAdapter.prototype.applyOperationToOrion = function (operation, orion) { + var ops = operation.ops; + var index = 0; // holds the current index into Orion's content + var oldLine = this.myLine; // Track the current line before this operation + var docLength = this.model.getCharCount(); // Track the doc length and verify it at the end + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (TextOperation.isRetain(op)) { + index += op; + if (index > docLength || index < 0) { + throw new Error('Invalid retain.'); + } + } else if (TextOperation.isInsert(op)) { + this.model.setText(op, index, i < (ops.length - 1) ? index : undefined); + index += op.length; + docLength += op.length; + this.requestInputChangedEvent(); + } else if (TextOperation.isDelete(op)) { + var from = index; + var to = index - op; + docLength += op; + if (to < 0) { + throw new Error('Invalid deletion'); + } + this.model.setText('', from, to); + this.requestInputChangedEvent(); + } + } + // Verify doc length + if (index !== docLength) { + throw new Error('Invalid doc length. Unsynchronized content.'); + } + // Check if current line is changed + var deltaLine = this.myLine - oldLine; + if (deltaLine !== 0) { + // Try to put the current line at the same position on the screen + var lineHeight = this.collabClient.textView.getLineHeight(); + var deltaY = deltaLine * lineHeight; + var originY = this.collabClient.textView.getTopPixel(); + this.collabClient.textView.setTopPixel(originY + deltaY); + } + }; + + OrionEditorAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + + // Give initial cursor position + var cursor = this.editor.getSelection().start; + this.selectionListener({ + newValue: { + start: cursor + } + }); + }; + + OrionEditorAdapter.prototype.onChanging = function (change) { + // By default, Orion's event order is the following: + // 1. 'ModelChanging', 2. 'ModelChanged' + // We want to fire save the deleted/replaced text during a 'modelChanging' event if applicable, + // so that we can use it to create the reverse operation used for the undo-stack after the model has changed. + if (change.removedCharCount > 0) { + this.deleteContent = this.model.getText(change.start, change.start + change.removedCharCount); + } + + this.changeInProgress = true; + }; + + OrionEditorAdapter.prototype.onChanged = function (change) { + var self = this; + this.changeInProgress = true; + if (!this.ignoreNextChange) { + var pair = this.operationFromOrionChanges([change], this.orion, this.deleteContent); + this.trigger('change', pair[0], pair[1]); + // Send editing flag + this.collabClient.editing = true; + if (this.editingTimeout) { + clearTimeout(this.editingTimeout); + } else { + this.collabClient.sendCurrentLocation(); + this.collabClient.updateSelfFileAnnotation(); + } + this.editingTimeout = setTimeout(function() { + self.editingTimeout = 0; + self.collabClient.editing = false; + self.collabClient.sendCurrentLocation(); + self.collabClient.updateSelfFileAnnotation(); + }, EDITING_FLAG_DURATION); + } + this.deleteContent = ""; + if (this.selectionChanged) { this.trigger('selectionChange'); } + this.changeInProgress = false; + // this.ignoreNextChange = false; + this.requestInputChangedEvent(); + }; + + OrionEditorAdapter.prototype.onCursorActivity = + OrionEditorAdapter.prototype.onFocus = function () { + if (this.changeInProgress) { + this.selectionChanged = true; + } else { + this.trigger('selectionChange'); + } + }; + + OrionEditorAdapter.prototype.onBlur = function () { + if (!this.orion.somethingSelected()) { this.trigger('blur'); } + }; + + OrionEditorAdapter.prototype.getValue = function () { + return this.model.getText(); + }; + + OrionEditorAdapter.prototype.getSelection = function () { + return ot.Selection.createCursor(this.editor.getSelection().start); + }; + + OrionEditorAdapter.prototype.setSelection = function (selection) { + // var ranges = []; + // for (var i = 0; i < selection.ranges.length; i++) { + // var range = selection.ranges[i]; + // ranges[i] = { + // anchor: this.orion.posFromIndex(range.anchor), + // head: this.orion.posFromIndex(range.head) + // }; + // } + // this.orion.setSelections(ranges); + }; + + var addStyleRule = (function () { + var added = {}; + var styleElement = document.createElement('style'); + document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement); + var styleSheet = styleElement.sheet; + + return function (css) { + if (added[css]) { return; } + added[css] = true; + styleSheet.insertRule(css, (styleSheet.cssRules || styleSheet.rules).length); + }; + }()); + + OrionEditorAdapter.prototype.selectionListener = function(e) { + var offset = e.newValue.start; + var currLine = this.model.getLineAtOffset(offset); + var lastLine = this.model.getLineCount()-1; + var lineStartOffset = this.model.getLineStart(currLine); + + if (offset) { + //decide whether or not it is worth sending (if line has changed or needs updating). + if (currLine !== this.myLine || currLine === lastLine || currLine === 0) { + // Send this change + } else { + return; + } + } + + this.myLine = currLine; + + // Self-tracking + var clientId = this.collabClient.getClientId(); + var peer = this.collabClient.getPeer(clientId); + var name = peer ? peer.name : undefined; + var color = peer ? peer.color : color; + var selection = ot.Selection.createCursor(offset); + this.updateLineAnnotation(clientId, selection, name, color); + + if (this.changeInProgress) { + this.selectionChanged = true; + } else { + this.trigger('selectionChange'); + } + }; + + OrionEditorAdapter.prototype.setOtherSelection = function (selection, color, clientId) { + if (clientId === this.collabClient.getClientId()) { + // Don't update self by remote + return { + clear: function() { + // NOOP + } + }; + } + var peer = this.collabClient.getPeer(clientId); + var name = peer ? peer.name : undefined; + color = peer ? peer.color : color; + this.updateLineAnnotation(clientId, selection, name, color); + var self = this; + return { + clear: function() { + self.destroyCollabAnnotations(clientId); + } + }; + }; + + OrionEditorAdapter.prototype.updateLineAnnotation = function(id, selection, name, color, force) { + force = !!force; + name = name || 'Unknown'; + color = color || '#000000'; + var cursor = selection.ranges[0].head || 0; + var annotationModel = this.editor.getAnnotationModel(); + var ann = this.AT.createAnnotation(this.AT.ANNOTATION_COLLAB_LINE_CHANGED, cursor, cursor, name + " is editing"); + var initial = mUIUtils.getNameInitial(name); + ann.html = ann.html.substring(0, ann.html.indexOf('>')) + " style='background-color:" + color + "'>" + initial + ""; + ann.peerId = id; + var peerId = id; + + /*if peer isn't being tracked yet, start tracking + * else replace previous annotation + */ + if (!(peerId in this.annotations && this.annotations[peerId]._annotationModel)) { + this.annotations[peerId] = ann; + annotationModel.addAnnotation(this.annotations[peerId]); + } else { + var currAnn = this.annotations[peerId]; + if (!force && ann.start === currAnn.start) return; + annotationModel.replaceAnnotations([currAnn], [ann]); + this.annotations[peerId] = ann; + } + }; + + /** + * Update the line annotation of a peer without change its line number + * i.e. only updates name and color + * @param {string} id - clientId + */ + OrionEditorAdapter.prototype.updateLineAnnotationStyle = function(id) { + var peer = this.collabClient.getPeer(id); + var name = peer ? peer.name : undefined; + var color = peer ? peer.color : undefined; + var annotation = this.annotations[id]; + if (!annotation) { + return; + } + var cursor = annotation.start; + var selection = ot.Selection.createCursor(cursor); + this.updateLineAnnotation(id, selection, name, color, true); + }; + + OrionEditorAdapter.prototype.destroyCollabAnnotations = function(peerId) { + var annotationModel = this.editor.getAnnotationModel(); + var currAnn = null; + + /*If a peer is specified, just remove their annotation + * Else remove all peers' annotations. + */ + if (peerId) { + if (this.annotations[peerId]) { + //remove that users annotation + currAnn = this.annotations[peerId]; + annotationModel.removeAnnotation(currAnn); + delete this.annotations[peerId]; + } + } else { + //the session has ended remove everyone's annotation + annotationModel.removeAnnotations(this.AT.ANNOTATION_COLLAB_LINE_CHANGED); + this.annotations = {}; + } + }; + + OrionEditorAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { action.apply(this, args); } + }; + + OrionEditorAdapter.prototype.applyOperation = function (operation) { + this.ignoreNextChange = true; + this.applyOperationToOrion(operation, this.model); + this.ignoreNextChange = false; + }; + + OrionEditorAdapter.prototype.registerUndo = function (undoFn) { + // this.orion.undo = undoFn; + this.orion.setAction("undo", undoFn); + }; + + OrionEditorAdapter.prototype.registerRedo = function (redoFn) { + // this.orion.redo = redoFn; + this.orion.setAction("redo", redoFn); + }; + + /** + * Trigger a delayed InputChanged event. + * In collab mode, client-side auto saving is disabled. As a result, the + * syntax checker won't work. So here we simulates a InputChanged event. + */ + OrionEditorAdapter.prototype.requestInputChangedEvent = function() { + if (!this.inputChangedRequested) { + this.inputChangedRequested = true; + var self = this; + var editor = self.collabClient.editor; + setTimeout(function() { + editor.onInputChanged({ + type: "InputChanged", //$NON-NLS-0$ + title: editor.getTitle(), + message: null, + contents: editor.getText(), + contentsSaved: true + }); + self.inputChangedRequested = false; + }, INPUT_CHANGED_EVENT_INTERVAL); + } + }; + + return { + OrionCollabSocketAdapter: OrionCollabSocketAdapter, + OrionEditorAdapter: OrionEditorAdapter + }; +}); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js new file mode 100644 index 0000000000..194fddde71 --- /dev/null +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js @@ -0,0 +1,62 @@ +/******************************************************************************* + * @license + * Copyright (c) 2016 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +/*eslint-env browser, amd*/ +/*global URL*/ +define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], function(xhr, Deferred, _, form) { + var contextPath = location.pathname.substr(0, location.pathname.lastIndexOf('/edit/edit.html')); + return { + shareProjectUrl: contextPath + "/sharedWorkspace/project/", + unshareProjectUrl: contextPath + "/sharedWorskpace/project/", + addUserUrl: contextPath + "/sharedWorkspace/user/", + removeUserUrl: contextPath + "/sharedWorkspace/user/", + shareProject: function(project) { + return xhr("POST", this.shareProjectUrl + project, { + headers: { + "Orion-Version": "1", + "X-Create-Options" : "no-overwrite", + "Content-Type": "application/json;charset=UTF-8" + } + }); + }, + unshareProject: function(project) { + return xhr("DELETE", this.unshareProjectUrl + project, { + headers: { + "Orion-Version": "1", + "X-Create-Options" : "no-overwrite", + "Content-Type": "application/json;charset=UTF-8" + } + }); + }, + addUser: function(username, project) { + return xhr("POST", this.addUserUrl + project + '/' + username, { + headers: { + "Orion-Version": "1", + "X-Create-Options" : "no-overwrite", + "Content-Type": "application/json;charset=UTF-8" + } + }).then(function() { + window.location.reload(); + }); + }, + removeUser: function(username, project) { + return xhr("DELETE", this.removeUserUrl + project + '/' + username, { + headers: { + "Orion-Version": "1", + "X-Create-Options" : "no-overwrite", + "Content-Type": "application/json;charset=UTF-8" + } + }).then(function() { + window.location.reload(); + }); + } + } +}); diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.css b/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.css index 3e2f4d82ac..1eef734f9a 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.css +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.css @@ -39,6 +39,10 @@ background-repeat: repeat-y; color: #CCCCCC; } +.annotation.collabLineChanged { + background-repeat: repeat-y; + color: #FFFFFF; +} .annotation.diffDeleted { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAFklEQVQYV2P8z8DwnwEJMCJzQGwMAQBhOAICSwrQUwAAAABJRU5ErkJggg==); background-repeat: repeat-x; @@ -54,6 +58,11 @@ background-color: rgba(85, 181, 219, 0.67); color: #555555; } +.lines .annotation.collabLineChanged { + background-image: none; + background-color: rgba(85, 181, 219, 0.67); + color: #555555; +} /* Styles for the annotation ruler (first line) */ .annotationHTML { @@ -169,6 +178,14 @@ background-repeat: repeat-y; background-position: left top; } +.annotationHTML.collabLineChanged { + text-align: center; + border-radius: 50%; + line-height: 16px; + font-size: 11px; + font-family: monospace; + font-weight: bold; +} /* Styles for the overview ruler */ .annotationOverview { @@ -243,6 +260,10 @@ background-color: rgba(85, 181, 219, 0.61); border: 1px solid black; } +.annotationOverview.collabLineChanged { + background-color: #84b3cf; + border: 1px solid #9cc2d8; +} /* Styles for text range */ .annotationRange { diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.js index a99b6c5a07..149d3b6466 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/annotations.js @@ -237,6 +237,10 @@ define("orion/editor/annotations", ['i18n!orion/editor/nls/messages', 'orion/edi * Diff Modification annotation type. */ AnnotationType.ANNOTATION_DIFF_MODIFIED = "orion.annotation.diffModified"; //$NON-NLS-0$ + /** + * Collab Line Change annotation type. + */ + AnnotationType.ANNOTATION_COLLAB_LINE_CHANGED = "orion.annotation.collabLineChanged"; //$NON-NLS-0$ /** @private */ var annotationTypes = {}; @@ -329,6 +333,7 @@ define("orion/editor/annotations", ['i18n!orion/editor/nls/messages', 'orion/edi registerType(AnnotationType.ANNOTATION_DIFF_ADDED); registerType(AnnotationType.ANNOTATION_DIFF_DELETED); registerType(AnnotationType.ANNOTATION_DIFF_MODIFIED); + registerType(AnnotationType.ANNOTATION_COLLAB_LINE_CHANGED, true); AnnotationType.registerType(AnnotationType.ANNOTATION_FOLDING, FoldingAnnotation); diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/nls/root/messages.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/nls/root/messages.js index f21c3cd5b8..da2dd07f04 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/nls/root/messages.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/nls/root/messages.js @@ -27,7 +27,7 @@ define({//Default message bundle "diffAdded": "Diff Added Lines", //$NON-NLS-1$ //$NON-NLS-0$ "diffDeleted": "Diff Deleted Lines", //$NON-NLS-1$ //$NON-NLS-0$ "diffModified": "Diff Modified Lines", //$NON-NLS-1$ //$NON-NLS-0$ - + "collabLineChanged": "Collab Line Changed", //$NON-NLS-1$ //$NON-NLS-0$ "lineUp": "Line Up", //$NON-NLS-1$ //$NON-NLS-0$ "lineDown": "Line Down", //$NON-NLS-1$ //$NON-NLS-0$ "lineStart": "Line Start", //$NON-NLS-1$ //$NON-NLS-0$ diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/projectionTextModel.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/projectionTextModel.js index b00af8dc5b..f61014b0ba 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/projectionTextModel.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/projectionTextModel.js @@ -1,6 +1,6 @@ /******************************************************************************* * @license - * Copyright (c) 2010, 2012 IBM Corporation and others. + * Copyright (c) 2010, 2012, 2016 IBM Corporation and others. * All rights reserved. This program and the accompanying materials are made * available under the terms of the Eclipse Public License v1.0 * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution @@ -107,9 +107,10 @@ define("orion/editor/projectionTextModel", ['orion/editor/textModel', 'orion/edi var removedLineCount = projection._lineCount; var addedCharCount = projection._model.getCharCount(); var addedLineCount = projection._model.getLineCount() - 1; + var changedText = projection._model.getText(); var modelChangingEvent = { type: "Changing", //$NON-NLS-0$ - text: projection._model.getText(), + text: changedText, start: eventStart, removedCharCount: removedCharCount, addedCharCount: addedCharCount, @@ -122,6 +123,7 @@ define("orion/editor/projectionTextModel", ['orion/editor/textModel', 'orion/edi var modelChangedEvent = { type: "Changed", //$NON-NLS-0$ start: eventStart, + changedText: text, removedCharCount: removedCharCount, addedCharCount: addedCharCount, removedLineCount: removedLineCount, @@ -208,10 +210,11 @@ define("orion/editor/projectionTextModel", ['orion/editor/textModel', 'orion/edi var addedLineCount = projection._lineCount; var removedCharCount = projection._model.getCharCount(); var removedLineCount = projection._model.getLineCount() - 1; + var changedText = model.getText(projection.start, projection.end); if (!noEvents) { var modelChangingEvent = { type: "Changing", //$NON-NLS-0$ - text: model.getText(projection.start, projection.end), + text: changedText, start: eventStart, removedCharCount: removedCharCount, addedCharCount: addedCharCount, @@ -225,6 +228,7 @@ define("orion/editor/projectionTextModel", ['orion/editor/textModel', 'orion/edi var modelChangedEvent = { type: "Changed", //$NON-NLS-0$ start: eventStart, + text: changedText, removedCharCount: removedCharCount, addedCharCount: addedCharCount, removedLineCount: removedLineCount, @@ -459,6 +463,7 @@ define("orion/editor/projectionTextModel", ['orion/editor/textModel', 'orion/edi var modelChangedEvent1 = { type: "Changed", //$NON-NLS-0$ start: change.start, + text: change.text, removedCharCount: change.removedCharCount, addedCharCount: change.addedCharCount, removedLineCount: change.removedLineCount, diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textModel.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textModel.js index a10a811913..6b96365eb3 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textModel.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textModel.js @@ -549,6 +549,8 @@ define("orion/editor/textModel", ['orion/editor/eventTarget', 'orion/regex', 'or }; this.onChanging(modelChangingEvent); + var changedText = text; + //TODO this should be done the loops below to avoid getText() if (newLineOffsets.length === 0) { var startLineOffset = this.getLineStart(startLine), endLineOffset; @@ -623,6 +625,7 @@ define("orion/editor/textModel", ['orion/editor/eventTarget', 'orion/regex', 'or var modelChangedEvent = { type: "Changed", //$NON-NLS-0$ start: eventStart, + text: changedText, removedCharCount: removedCharCount, addedCharCount: addedCharCount, removedLineCount: removedLineCount, diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index c1ade926a0..84abdfad51 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -744,7 +744,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ end += text.length; style = range.style; if (oldSpan) { - oldText = oldSpan.firstChild.data; + oldText = oldSpan.firstChild ? oldSpan.firstChild.data : " "; oldStyle = oldSpan.viewStyle; if (oldText === text && compare(style, oldStyle)) { oldEnd += oldText.length; @@ -758,7 +758,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ if (spanEnd >= changeStart) { spanEnd -= changeCount; } - var t = oldSpan.firstChild.data; + var t = oldSpan.firstChild ? oldSpan.firstChild.data : " "; var len = t ? t.length : 0; if (oldEnd + len > spanEnd) { break; } oldEnd += len; @@ -6598,7 +6598,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ } else { if (e.selection.length > 1) this._startUndo(); } - + var model = this._model; try { if (e._ignoreDOMSelection) { this._ignoreDOMSelection = true; } @@ -6616,7 +6616,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ if (e._ignoreDOMSelection) { this._ignoreDOMSelection = false; } } this._setSelection(e.selection, show, true, callback); - + undo = this._compoundChange; if (undo) undo.owner.selection = e.selection; diff --git a/bundles/org.eclipse.orion.client.ui/web/css/controls.css b/bundles/org.eclipse.orion.client.ui/web/css/controls.css index 920ec7ec5c..8fd4e5ce24 100644 --- a/bundles/org.eclipse.orion.client.ui/web/css/controls.css +++ b/bundles/org.eclipse.orion.client.ui/web/css/controls.css @@ -13,6 +13,7 @@ .navlink > span { color: inherit; + vertical-align: text-bottom; } .nav_fakelink:hover { @@ -1601,3 +1602,84 @@ html[dir="rtl"] .fileExplorerProgressDiv { /* ACGC */ margin-left: auto; } +.treeAnnotation { + pointer-events: none; + display: inline-block; + vertical-align: bottom; + padding: 0 2px; + height: 20px; +} + +.navAnnotation .collabAnnotation { + pointer-events: initial; + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + font-size: 11px; + font-weight: bold; + font-family: monospace; + line-height: 16px; + text-align: center; + color: #fff; + margin: 2px 2px 2px 2px; + cursor: pointer; + vertical-align: bottom; +} + +.collabAnnotation.collabEditing { + animation: collab-editing 1s infinite; +} + +@keyframes collab-editing { + 0% { + box-shadow: rgba(127, 191, 255, 0) 0 0 4px 2px; + } + 50% { + box-shadow: rgba(127, 191, 255, 1) 0 0 4px 2px; + } + 100% { + box-shadow: rgba(127, 191, 255, 0) 0 0 4px 2px; + } +} + +.treeAnnotation .overlay { + width: 7px; + height: 7px; + display: inline-block; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAAXNSR0IArs4c6QAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJEAQvB2JVdrAAAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAAD1JREFUCNdtjkESADAEAzemf69f66HMqGlOIhYiFRFRtSQBWAY7mzx+EDTL6sSgb1jTk7Q87rxyqe37fXsAa78gLyZnRgEAAAAASUVORK5CYII="); + position: relative; + left: -7px; + top: 7px; +} + +.treeAnnotation .editingAnnotation { + height: 16px; + line-height: 16px; + font-size: 16px; + text-shadow: 0px 0px 1px #000000; + display: inline-block; + cursor: pointer; + margin: 2px; + pointer-events: initial; + vertical-align: bottom; +} + +.treetable-tooltip { + z-index: 999; + padding: 10px; + background-color: #fff; + border-width: 1px; + border-style: solid; + word-break: break-all; + width: 200px; + min-height: 20px; + position: absolute; +} + +.treetable-tooltip .cross { + position: absolute; + right: 3px; + top: 1px; + cursor: pointer; +} diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js b/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js index 3991c9f9a7..1e9c0544d1 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js @@ -120,6 +120,9 @@ define({ "selectNextTab": "Select Next Editor Tab", "selectPreviousTab": "Select Previous Editor Tab", "showTabDropdown": "Display Open Editor Tabs", + "Collaborate": "Collaborate", + "CollaborateToolTip": "Start a Collaboration session on the current file" + "showTabDropdown": "Display Open Editor Tabs", "closeOthers":"Close Others Tabs", "closeTotheRight":"Close Tabs To The Right", "keepOpen":"Keep Open", diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/editorView.js b/bundles/org.eclipse.orion.client.ui/web/orion/editorView.js index 5b83802cd7..f4fbd98f2c 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/editorView.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/editorView.js @@ -1,6 +1,6 @@ /******************************************************************************* * @license - * Copyright (c) 2010, 2016 IBM Corporation and others. + * Copyright (c) 2010, 2016, 2017 IBM Corporation and others. * All rights reserved. This program and the accompanying materials are made * available under the terms of the Eclipse Public License v1.0 * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution @@ -47,7 +47,7 @@ define([ 'orion/commonPreferences', 'embeddedEditor/helper/memoryFileSysConst', 'orion/objects', - 'orion/formatter' + 'orion/formatter' ], function( messages, mEditor, mAnnotations, mEventTarget, mTextView, mTextModelFactory, mEditorFeatures, mHoverFactory, mContentAssist, @@ -484,6 +484,15 @@ define([ domNode: this._parent, syntaxHighlighter: this.syntaxHighlighter }); + this.preferences.get("/plugins").then(function(plugins) { + if (plugins["collab/plugins/collabPlugin.html"]) { + require(['orion/collab/collabClient'], function(mCollabClient){ + mCollabClient.init(function() { + var collab = new mCollabClient.CollabClient(editor, that.inputManager, that.fileClient, that.serviceRegistry, that.commandRegistry, that.preferences); + }) + }); + } + }); editor.id = "orion.editor"; //$NON-NLS-0$ editor.processParameters = function(params) { parseNumericParams(params, ["start", "end", "line", "offset", "length"]); //$NON-NLS-4$ //$NON-NLS-3$ //$NON-NLS-2$ //$NON-NLS-1$ //$NON-NLS-5$ diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js index 410513cc26..e4fa017bfb 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js @@ -1034,7 +1034,15 @@ define([ var promise = this.dragAndDrop(targetItem, file, this, unzip, false, true); var done = function() { destroy(); - this.changedItem(targetItem, true); + this.fileClient.dispatchEvent({ + type: "Changed", + created: [{ + parent: targetItem.Location, + eventData: { + select: false + } + }] + }); }.bind(this); promise.then( done, diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/navigatorRenderer.js b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/navigatorRenderer.js index 8453b17f04..f22de3e0d6 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/navigatorRenderer.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/navigatorRenderer.js @@ -1,6 +1,6 @@ /******************************************************************************* * @license - * Copyright (c) 2009, 2013 IBM Corporation and others. + * Copyright (c) 2009, 2013, 2017 IBM Corporation and others. * All rights reserved. This program and the accompanying materials are made * available under the terms of the Eclipse Public License v1.0 * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution @@ -326,6 +326,12 @@ define([ col.appendChild(span); col.setAttribute("role", "presentation"); //$NON-NLS-1$ //$NON-NLS-2$ span.className = "mainNavColumn"; //$NON-NLS-0$ + // Append annotation container + var annotation = document.createElement("span"); //$NON-NLS-0$ + annotation.id = tableRow.id + "Annotation"; //$NON-NLS-0$ + annotation.classList.add("treeAnnotation"); + annotation.classList.add("navAnnotation"); + col.appendChild(annotation); var itemNode; if(this.explorer._parentId === "pageSidebar"){ var isDesktopMode = this.explorer._parentNode.parentNode.classList.contains("desktopmode"); diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js b/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js index e2e2b97571..0aaaf360bb 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js @@ -1263,7 +1263,8 @@ define(['i18n!orion/navigate/nls/messages', 'orion/webui/littlelib', 'orion/i18n } } }); - commandService.addCommand(pasteFromBufferCommand); + commandService.addCommand(pasteFromBufferCommand); + return new Deferred().resolve(); }; diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/inputManager.js b/bundles/org.eclipse.orion.client.ui/web/orion/inputManager.js index 693cd0b001..0a6c2cbef6 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/inputManager.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/inputManager.js @@ -165,6 +165,7 @@ define([ } }.bind(this)); } + this.syncEnabled = true; } objects.mixin(InputManager.prototype, /** @lends orion.editor.InputManager.prototype */ { /** @@ -224,7 +225,7 @@ define([ progress(fileClient.read(resource, true), messages.ReadingMetadata, fileURI).then(function(data) { if (this._fileMetadata && !this._fileMetadata._saving && this._fileMetadata.Location === data.Location && this._fileMetadata.ETag !== data.ETag) { this._fileMetadata = objects.mixin(this._fileMetadata, data); - if (!editor.isDirty() || window.confirm(messages.loadOutOfSync)) { + if (this.syncEnabled && (!editor.isDirty() || window.confirm(messages.loadOutOfSync))) { progress(fileClient.read(resource), messages.Reading, fileURI).then(function(contents) { editor.setInput(fileURI, null, contents, null, nofocus); this._clearUnsavedChanges(); @@ -356,11 +357,11 @@ define([ onFocus: function() { // If there was an error while auto saving, auto save is temporarily disabled and // we retry saving every time the editor gets focus - if (this._autoSaveEnabled && this._errorSaving) { + if (this._autoSaveEnabled && this._errorSaving && this.syncEnabled) { this.save(); return; } - if (this._autoLoadEnabled && this._fileMetadata) { + if (this._autoLoadEnabled && this._fileMetadata && this.syncEnabled) { this.load(); } }, @@ -385,7 +386,7 @@ define([ return deferred; } var editor = this.getEditor(); - if (!editor || !editor.isDirty() || this.getReadOnly()) { return done(); } + if (!this.syncEnabled || !editor || !editor.isDirty() || this.getReadOnly()) { return done(); } var failedSaving = this._errorSaving; var input = this.getInput(); this.reportStatus(messages['Saving...']); @@ -497,7 +498,7 @@ define([ }; this._idle = new Idle(options); this._idle.addEventListener("Idle", function () { //$NON-NLS-0$ - if (!this._errorSaving) { + if (!this._errorSaving && this.syncEnabled) { this._autoSaveActive = true; this.save().then(function() { this._autoSaveActive = false; @@ -629,7 +630,7 @@ define([ if (this._autoSaveEnabled) { this.save(); afterConfirm(); - } else if(this.isUnsavedWarningNeeed()) { + } else if(this.syncEnabled && this.isUnsavedWarningNeeed()) { var cancelCallback = function() { window.location.hash = oldLocation; this.reveal(this.getFileMetadata()); @@ -785,6 +786,9 @@ define([ evt.session.apply(); } } + evt = {}; + evt.type = 'InputContentsSet'; + this.editor.dispatchEvent(evt); } this._saveEventLogged = false; diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/uiUtils.js b/bundles/org.eclipse.orion.client.ui/web/orion/uiUtils.js index 7704bf92bc..310562b399 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/uiUtils.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/uiUtils.js @@ -1,6 +1,6 @@ /******************************************************************************* * @license - * Copyright (c) 2009, 2012 IBM Corporation and others. + * Copyright (c) 2009, 2012, 2017 IBM Corporation and others. * All rights reserved. This program and the accompanying materials are made * available under the terms of the Eclipse Public License v1.0 * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution @@ -406,6 +406,23 @@ define([ } return messages["justNow"]; } + + /** + * Returns the initial of given name. + * For standard names: "First [Midddles] Last", returns capital "FL"; + * For others, returns substr(0, 2). + * + * @param {string} name - + * @return {string} - initial + */ + function getNameInitial(name) { + var namePart = name.split(' '); + if (namePart.length >= 2) { + return (namePart[0].charAt(0) + namePart[namePart.length - 1].charAt(0)).toUpperCase(); + } else { + return name.substr(0, 2); + } + } //return module exports return { getUserKeyString: getUserKeyString, @@ -417,6 +434,7 @@ define([ isFormElement: isFormElement, path2FolderName: path2FolderName, timeElapsed: timeElapsed, - displayableTimeElapsed: displayableTimeElapsed + displayableTimeElapsed: displayableTimeElapsed, + getNameInitial: getNameInitial }; }); diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js b/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js index 6977713594..84f1762133 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js @@ -1,6 +1,6 @@ /******************************************************************************* * @license - * Copyright (c) 2010, 2014 IBM Corporation and others. + * Copyright (c) 2010, 2014, 2017 IBM Corporation and others. * All rights reserved. This program and the accompanying materials are made * available under the terms of the Eclipse Public License v1.0 * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution @@ -10,7 +10,7 @@ ******************************************************************************/ /*eslint-env browser, amd*/ -define(['i18n!orion/nls/messages', 'orion/webui/littlelib'], function(messages, lib) { +define(['i18n!orion/nls/messages', 'orion/webui/littlelib', 'orion/Deferred'], function(messages, lib, Deferred) { /** * Constructs a new TableTree with the given options. @@ -34,6 +34,7 @@ define(['i18n!orion/nls/messages', 'orion/webui/littlelib'], function(messages, *
  • getRoot(onItem)
  • *
  • getChildren(parentItem, onComplete)
  • *
  • getId(item) // must be a valid DOM id
  • + *
  • (optional) getAnnotations() // returns an array of Annotation
  • * * * Renderers must implement: @@ -75,6 +76,7 @@ define(['i18n!orion/nls/messages', 'orion/webui/littlelib'], function(messages, this._tableElement = options.tableElement || "table"; //$NON-NLS-0$ this._tableBodyElement = options.tableBodyElement || "tbody"; //$NON-NLS-0$ this._tableRowElement = options.tableRowElement || "tr"; //$NON-NLS-0$ + this._annotationRefreshRequested = false; // Generate the table this._treeModel.getRoot(function (root) { @@ -173,6 +175,109 @@ define(['i18n!orion/nls/messages', 'orion/webui/littlelib'], function(messages, if (this._renderer.rowsChanged) { this._renderer.rowsChanged(); } + this.requestAnnotationRefresh(); + }, + + /** + * Redraw the annotation according to model.getAnnotations(). + * + * This function assumes model.getAnnotations() exists. + */ + _redrawAnnotation: function() { + // TODO: should I move this part to renderer? If so, how? + var tree = this; + var annotations = tree._treeModel.getAnnotations(); + console.assert(Array.isArray(annotations)); + // Remove existing annotations + var existingAnnotations = this._parent.getElementsByClassName('treeAnnotationOn'); + var annotationsToRemove = []; + for (var i = 0; i < existingAnnotations.length; i++){ + annotationsToRemove.push(existingAnnotations[i]); + }; + annotationsToRemove.forEach(function(annotation) { + while (annotation.firstChild) { + annotation.removeChild(annotation.firstChild); + } + annotation.classList.remove('treeAnnotationOn'); + }) + // Add new annotations + // A map from tree item ID to a list of its annotation HTML elements + var annotationElementsByItem = {}; + var promises = []; + annotations.forEach(function(annotation) { + var promise = new Deferred(); + promises.push(promise); + annotation.findDeepestFitId(tree._treeModel, function(id) { + // Make sure there is a place to show the annotation + if (!id) { + promise.resolve(); + return; + } + var container = document.getElementById(id + 'Annotation'); + if (!container) { + promise.resolve(); + return; + } + var annotationElement = annotation.generateHTML(); + if (!annotationElement) { + promise.resolve(); + return; + } + if (!annotationElementsByItem[id]) { + annotationElementsByItem[id] = []; + } + annotationElement.annotation = annotation; + annotationElementsByItem[id].push(annotationElement); + promise.resolve(); + }); + }); + Deferred.all(promises).then(function() { + // All async calls ends. Add these annotations now. + for (var elementid in annotationElementsByItem) { + if (annotationElementsByItem.hasOwnProperty(elementid)) { + var annotationsToAdd = annotationElementsByItem[elementid]; + var container = document.getElementById(elementid + 'Annotation'); + container.classList.add('treeAnnotationOn'); + // var html = annotationsToAdd[0]; + // container.appendChild(html); + // if (annotationsToAdd.length > 1) { + // var overlay = document.createElement('div'); + // overlay.classList.add('overlay'); + // container.appendChild(overlay); + // } + var showEditing = false; + annotationsToAdd.forEach(function (html) { + container.appendChild(html); + // Tooltip + // TODO: make it general + html.addEventListener('click', function(e) { + if (tree.tooltip) { + //tree.tooltip.remove(); + } + var tooltip = document.createElement('div'); + tooltip.innerHTML = html.annotation.getDescription(); + tooltip.classList.add("treetable-tooltip"); + tooltip.style.left = e.clientX + 'px'; + tooltip.style.top = e.clientY + 'px'; + var cross = document.createElement('div'); + cross.innerHTML = '×'; + cross.classList.add("cross"); + tooltip.appendChild(cross); + document.body.appendChild(tooltip); + tooltip.remove = function (e2) { + window.removeEventListener('click', tooltip.remove); + document.body.removeChild(tooltip); + tree.tooltip = null; + }; + setTimeout(function() { + window.addEventListener('click', tooltip.remove); + }, 0); + tree.tooltip = tooltip; + }); + }); + } + } + }); }, getSelected: function() { @@ -242,6 +347,28 @@ define(['i18n!orion/nls/messages', 'orion/webui/littlelib'], function(messages, } } }, + + /** + * Request an annotation refresh using the information provided by + * model.getAnnotations(). + */ + requestAnnotationRefresh: function() { + if (!this._treeModel.getAnnotations) { + return; + } + + if (this._annotationRefreshRequested) { + return; + } + + // Refresh annotation in next tick to avoid duplicate requests + var tree = this; + this._annotationRefreshRequested = true; + setTimeout(function() { + tree._annotationRefreshRequested = false; + tree._redrawAnnotation(); + }, 0); + }, getContentNode: function() { return this._bodyElement; @@ -397,6 +524,63 @@ define(['i18n!orion/nls/messages', 'orion/webui/littlelib'], function(messages, } }; // end prototype TableTree.prototype.constructor = TableTree; + + /** + * Annotation descriptor. + * + * Note: I'd like to use the annotation in editor bundle. However that one + * is not in ui bundle and coupling to the editor too much. + * + * @interface + * @name {orion.treetable.TableTree.IAnnotation} + */ + TableTree.IAnnotation = function() {}; + + /** + * Get the deepest item that this annotation should display at. + * + * @example + * Suppose we have a file located in /folder/subfolder/file.ext and + * the tree table is in this status: + * - (/) + * - (/folder) + * + (/folder/subfolder) <- this folder collapses + * Then the annotation of /folder/subfolder/file.ext should be at + * /folder/subfolder since it is the deepest item fits it. + * + * @abstract + * + * @param {orion.explorer.ExplorerModel} model - the model of the tree + * @param {Function} - callback that takes the DOM ID of an item from the + * model as parameter + */ + TableTree.IAnnotation.prototype.findDeepestFitId = function(model, callback) { + throw new Error('Not implemented.'); + }; + + /** + * Get description of this annotation which can be used in for examole + * tooltip. + * + * @abstract + * + * @return {string} - description + */ + TableTree.IAnnotation.prototype.getDescription = function() { + throw new Error('Not implemented.'); + }; + + /** + * Generate a new HTML element of this annotation. + * + * @abstract + * + * @return {Element} - the HTML element of this annotation + */ + TableTree.IAnnotation.prototype.generateHTML = function() { + throw new Error('Not implemented.'); + }; + //return module exports return {TableTree: TableTree}; }); diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/widgets/nav/common-nav.js b/bundles/org.eclipse.orion.client.ui/web/orion/widgets/nav/common-nav.js index d60aa7b8ae..1bea521160 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/widgets/nav/common-nav.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/widgets/nav/common-nav.js @@ -333,22 +333,31 @@ define([ commandRegistry.registerCommandContribution(contextMenuActionsScope, commandId, 1, "orion.commonNavContextMenuGroup/orion.relatedActions/orion.Extensions"); //$NON-NLS-0$ }); - // Retrieve and register project commands - return this.preferences.get("/common-nav").then(function(prefs) { //$NON-NLS-0$ - var show = prefs["showNewProjectCommands"]; - if (show === undefined || show) { - commandRegistry.addCommandGroup(fileActionsScope, "orion.projectsNewGroup", 100, messages["Project"], "orion.menuBarFileGroup/orion.newContentGroup"); //$NON-NLS-1$ //$NON-NLS-0$ - commandRegistry.addCommandGroup(contextMenuActionsScope, "orion.projectsNewGroup", 100, messages["Project"], "orion.commonNavContextMenuGroup/orion.newGroup/orion.New"); //$NON-NLS-1$ //$NON-NLS-0$ - var position = 0; - ProjectCommands.getCreateProjectCommands(commandRegistry).forEach(function(command) { - if (!util.isElectron) { - commandRegistry.registerCommandContribution(fileActionsScope, command.id, position, "orion.menuBarFileGroup/orion.newContentGroup/orion.projectsNewGroup"); //$NON-NLS-0$ - commandRegistry.registerCommandContribution(contextMenuActionsScope, command.id, position, "orion.commonNavContextMenuGroup/orion.newGroup/orion.New/orion.projectsNewGroup"); //$NON-NLS-0$ - } - position++; - }); - } - }); + return Deferred.all([ + // Retrieve and register project commands + this.preferences.get("/common-nav").then(function(prefs) { //$NON-NLS-0$ + var show = prefs["showNewProjectCommands"]; + if (show === undefined || show) { + commandRegistry.addCommandGroup(fileActionsScope, "orion.projectsNewGroup", 100, messages["Project"], "orion.menuBarFileGroup/orion.newContentGroup"); //$NON-NLS-1$ //$NON-NLS-0$ + commandRegistry.addCommandGroup(contextMenuActionsScope, "orion.projectsNewGroup", 100, messages["Project"], "orion.commonNavContextMenuGroup/orion.newGroup/orion.New"); //$NON-NLS-1$ //$NON-NLS-0$ + var position = 0; + ProjectCommands.getCreateProjectCommands(commandRegistry).forEach(function(command) { + if (!util.isElectron) { + commandRegistry.registerCommandContribution(fileActionsScope, command.id, position, "orion.menuBarFileGroup/orion.newContentGroup/orion.projectsNewGroup"); //$NON-NLS-0$ + commandRegistry.registerCommandContribution(contextMenuActionsScope, command.id, position, "orion.commonNavContextMenuGroup/orion.newGroup/orion.New/orion.projectsNewGroup"); //$NON-NLS-0$ + } + position++; + }); + } + }), + //Collaboration Mode + this.preferences.get("/plugins").then(function(plugins) { + if (plugins["collab/plugins/collabPlugin.html"]) { + commandRegistry.registerCommandContribution(contextMenuActionsScope, "orion.collab.shareProject", 1); + commandRegistry.registerCommandContribution(contextMenuActionsScope, "orion.collab.unshareProject", 1); + } + }) + ]); }, /** * @callback diff --git a/bundles/org.eclipse.orion.client.ui/web/plugins/authenticationPlugin.js b/bundles/org.eclipse.orion.client.ui/web/plugins/authenticationPlugin.js index bee2397441..95f1f058d6 100644 --- a/bundles/org.eclipse.orion.client.ui/web/plugins/authenticationPlugin.js +++ b/bundles/org.eclipse.orion.client.ui/web/plugins/authenticationPlugin.js @@ -1,6 +1,6 @@ /******************************************************************************* * @license - * Copyright (c) 2017 IBM Corporation and others. + * Copyright (c) 2012 IBM Corporation and others. * All rights reserved. This program and the accompanying materials are made * available under the terms of the Eclipse Public License v1.0 * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution @@ -43,6 +43,9 @@ define([ timeout: 15000 }).then(function(result) { loginData = result.response ? JSON.parse(result.response) : null; + if (loginData) { + localStorage.setItem('orion.user', JSON.stringify(loginData)) + } return loginData; }, function(error) { loginData = null; @@ -55,6 +58,7 @@ define([ }, logout: function() { /* don't wait for the login response, notify anyway */ loginData = null; + localStorage.removeItem('orion.user') return xhr("POST", "../logout", { //$NON-NLS-0$ headers: { "Orion-Version": "1" //$NON-NLS-0$ diff --git a/modules/orionode.collab.hub/client.js b/modules/orionode.collab.hub/client.js new file mode 100644 index 0000000000..bce492dd46 --- /dev/null +++ b/modules/orionode.collab.hub/client.js @@ -0,0 +1,73 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +'use strict'; + +var hsl2rgb = require('hsl-to-rgb-for-reals'); + +/** + * A client record + */ +class Client { + /** + * @param {string} clientId + * @param {string} name + */ + constructor(clientId, name) { + this.clientId = clientId; + this.name = name; + this.color = generateColorByName(name); + /** Location for internal track */ + this.doc = ''; + /** Location for display */ + this.location = ''; + /** @type ot.Selection */ + this.selection = null; + this.editing = false; + } + + /** + * Serialize this client to a JSON object + */ + serialize() { + return { + clientId: this.clientId, + name: this.name, + color: this.color, + location: this.location, + editing: this.editing + } + } +}; + +var MASK = 360; +var PRIME = 271; +var SATURATION = 0.7; +var LIGHTNESS = 0.5; + +/** + * Generate an RGB value from a string + * + * @param {string} str + * + * @return {string} - RGB value + */ +function generateColorByName(str) { + var hue = 0; + for (var i = 0; i < str.length; i++) { + hue = (hue * PRIME + str.charCodeAt(i)) % MASK; + } + hue = Math.floor(hue * PRIME) % MASK; + var rgb = hsl2rgb(hue, SATURATION, LIGHTNESS); + return ('#' + rgb[0].toString(16) + rgb[1].toString(16) + rgb[2].toString(16)).toUpperCase(); +} + +module.exports = Client; diff --git a/modules/orionode.collab.hub/config.js b/modules/orionode.collab.hub/config.js new file mode 100644 index 0000000000..99b9891de8 --- /dev/null +++ b/modules/orionode.collab.hub/config.js @@ -0,0 +1,37 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +module.exports = { + /** + * jwt_secret needs to be the same as the one in the server connected to the filesystem (orion) + */ + 'jwt_secret': "pomato (potato and tomato mix lol)", + /** + * Orion url + */ + 'orion': "http://localhost:8081/", + /** + * Load url. Make sure end with / + */ + 'fileLoadUrl': "sharedWorkspace/tree/load/", + /** + * Save url. Make sure end with / + */ + 'fileSaveUrl': "sharedWorkspace/tree/save/", + /** + * Check session url. Make sure end with / + */ + 'checkSessionUrl': "sharedWorkspace/tree/session/", + /** + * Specify how long between every saving (in ms) + */ + 'saveFrequency': 1000 +} diff --git a/modules/orionode.collab.hub/document.js b/modules/orionode.collab.hub/document.js new file mode 100644 index 0000000000..fca462f7a4 --- /dev/null +++ b/modules/orionode.collab.hub/document.js @@ -0,0 +1,340 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +'use strict'; + +var config = require('./config.js'); +var jwt = require('jsonwebtoken'); +var ot = require('ot'); +var parseUrl = require('url').parse; +var Request = require('request'); + +var FILE_LOAD_URL = config.orion + config.fileLoadUrl; +var FILE_SAVE_URL = config.orion + config.fileSaveUrl; +var SAVE_FREQUENCY = config.saveFrequency; +var SERVER_TOKEN = jwt.sign({}, config.jwt_secret); + +/** +* This class defines an active document. +* It includes document specific data about clients, deals with the OT and connects to the filesystem. +*/ +class Document { + + /** + * @param id - the doc id. + * @param sessionId - the id of the session + */ + constructor(id, sessionId) { + /** @type {Map.} */ + this.clients = new Map(); + /** @type {ot.Server} */ + this.ot = null; + this.id = id; + this.sessionId = sessionId; + this.awaitingDoc = false; + this.waitingConnections = new Set(); + this.discard = false; + this.saveTimeout = 0; + } + + /** + * Start OT server + * + * @return {Promise} + */ + startOT() { + if (this.awaitingDoc) { + return new Promise.resolve(); + } + this.awaitingDoc = true; + var self = this; + return this.getDocument() + .then(function(text, error) { + if (error) { + console.log('Failed to get initial content.'); + } + self.ot = new ot.Server(text); + console.log('OT instance started for ' + self.id); + self.awaitingDoc = false; + self.waitingConnections.forEach(function(c) { + self.sendInit(c); + }); + self.waitingConnections = new Set(); + }); + } + + /** + * Cleanup + */ + destroy() { + console.log('OT instance ended for ' + this.id); + } + + /** + * Add a client to this document + * + * @param {WebSocket} connection + * @param {string} clientId + * @param {Client} client + */ + joinDocument(connection, clientId, client) { + if (!this.clients.has(connection) && client) { + this.clients.set(connection, client); + client.selection = new ot.Selection.createCursor(0); + } + + var message = { + 'type': 'client-joined-doc', + 'clientId': clientId, + 'client': client, + 'doc': this.id + }; + + this.notifyOthers(connection, message); + + this.sendInit(connection); + } + + /** + * Remove a client from the document + */ + leaveDocument(connection, clientId, callback) { + var has = this.clients.has(connection); + + if (!has) { + return; + } + + //delete the client + this.clients.delete(connection); + + var message = { + 'type': 'client-left-doc', + 'clientId': clientId, + 'doc': this.id + }; + + if (this.clients.size == 0) { + this.saveDocument() + .then(function() { + callback(true); + }); + } else { + this.notifyOthers(null, message); + callback(false); + } + } + + /** + * Handle incoming message + * + * @param {WebSocket} connection + * @param {Object} msg + * @param {Client} client + */ + onmessage(connection, msg, client) { + if (msg.type == 'join-document') { + this.joinDocument(connection, msg.clientId, client); + } else if (msg.type == 'operation') { + try { + var operation = this.newOperation(msg.operation, msg.revision); + var outMsg = { + type: 'operation', + doc: this.id, + clientId: client.clientId, + operation: operation, + guid: msg.guid + }; + connection.send(JSON.stringify({ + 'type': 'ack', + 'doc': this.id + })); + this.notifyOthers(connection, outMsg); + } catch (ex) { + console.warn(ex); + var self = this; + this.clients.forEach(function(client, c) { + self.sendInit(c); + }); + } + } else if (msg.type == 'selection') { + var client = this.clients.get(connection); + if (client) { + client.selection = msg.selection; + } + this.notifyOthers(connection, msg); + } else if (msg.type == 'get-selection') { + this.sendAllSelections(connection); + } + } + + /** + * Initialize client's document + * + * @param {WebSocket} c + */ + sendInit(c) { + var self = this; + // if doc being grabbed by other user, add this user to waiting list for receiving it. + if (this.awaitingDoc) { + this.waitingConnections.add(c); + return; + } + + var message = JSON.stringify({ + type: 'init-document', + operation: new ot.TextOperation().insert(this.ot.document), + revision: this.ot.operations.length, + doc: this.id + }); + c.send(message); + + // Also send all peer selections + this.clients.forEach(function(client) { + c.send(JSON.stringify({ + type: 'selection', + doc: self.id, + clientId: client.clientId, + selection: client.selection + })); + }); + } + + /** + * Get document content + * + * @return {Promise} + */ + getDocument() { + var self = this; + return new Promise(function(resolve, reject) { + Request({ + uri: FILE_LOAD_URL + self.sessionId + '/' + self.id, + headers: { + Authorization: 'Bearer ' + SERVER_TOKEN + } + }, function(error, response, body) { + if (!error) { + resolve(body); + } else { + reject(error); + } + }); + }); + } + + /** + * Save this document + * + * @param {string} [path] - Path to save. Default to this file. This + * parameter is useful when renaming a file. + * + * @return {Promise} + */ + saveDocument(path) { + path = path || this.id; + var self = this; + return new Promise(function(resolve, reject) { + if (self.discard) { + resolve(); + } + var headerData = { + "Orion-Version": "1", + "Content-Type": "text/plain; charset=UTF-8", + "Authorization": 'Bearer ' + SERVER_TOKEN + }; + Request({ + method: 'PUT', + uri: FILE_SAVE_URL + self.sessionId + '/' + path, + headers: headerData, + body: self.ot.document + }, function(error, response, body) { + if (body && !error) { + resolve(); + } else { + //reject(); + console.error('Failed to save file ' + path); + resolve(); + } + }); + }); + } + + /** + * Send every client's selection + * + * @param {WebSocket} connection + */ + sendAllSelections(connection) { + this.clients.forEach(function(client, clientConnection) { + if (connection !== clientConnection) { + connection.send(JSON.stringify({ + clientId: client.clientId, + selection: client.selection + })); + } + }); + } + + /** + * Generate a transformed operation + * This method takes a raw operation from a client, apply the operation to + * the server and get the transformed operation. + * + * @param {string} operation - opeartion JSON string + * @param {number} revision + * + * @return {ot.Operation} - transformed operation + */ + newOperation(operation, revision) { + var self = this; + var operation = ot.TextOperation.fromJSON(operation); + operation = this.ot.receiveOperation(revision, operation); + // Save + if (!this.saveTimeout) { + this.saveTimeout = setTimeout(function() { + self.saveTimeout = 0; + self.saveDocument().then(function(success, error) { + if (error) { + console.error(error); + } else { + console.log(self.id + ' is saved.'); + } + }) + }, SAVE_FREQUENCY); + } + return operation; + } + + /** + * Broadcast message + * + * @param {WebSocket} connection + * @param {Object} message + * @param {boolean} [includeSender=false] + */ + notifyOthers(connection, message, includeSender) { + includeSender = !!includeSender; + var msgStr = JSON.stringify(message); + this.clients.forEach(function(client, conn) { + if (conn === connection && !includeSender) { + return; + } + try { + conn.send(msgStr); + } catch (ex) { + console.error(ex); + } + }); + } +} + +module.exports = Document; diff --git a/modules/orionode.collab.hub/package.json b/modules/orionode.collab.hub/package.json new file mode 100644 index 0000000000..5e07f25750 --- /dev/null +++ b/modules/orionode.collab.hub/package.json @@ -0,0 +1,28 @@ +{ + "name": "collab-socket-server", + "version": "0.1.0", + "main": "server.js", + "description": "A WebSocket server for Orion collaboration feature.", + "keywords": [], + "author": "Orion contributors ", + "license": "Eclipse Public License + Eclipse Distribution License", + "repository": { + "type": "git", + "url": "" + }, + "dependencies": { + "express": "^4.13.3", + "jsonwebtoken": "^7.2.0", + "ot": "^0.0.15", + "request": "^2.79.0", + "ws": "^1.1.1", + "hsl-to-rgb-for-reals": "^1.1.0" + }, + "devDependencies": {}, + "engines": { + "node": "^4.0.0" + }, + "scripts": { + "start": "node server.js" + } +} diff --git a/modules/orionode.collab.hub/server.js b/modules/orionode.collab.hub/server.js new file mode 100644 index 0000000000..c1d061dc81 --- /dev/null +++ b/modules/orionode.collab.hub/server.js @@ -0,0 +1,92 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +'use strict'; + +var config = require('./config'); +var express = require('express'); +var http = require('http'); +var jwt = require('jsonwebtoken'); +var SessionManager = require('./session_manager'); +var url = require('url'); +var ws = require('ws'); + +var JWT_SECRET = config.jwt_secret; + +var app = express(); +var server = http.createServer(app); +var wss = new ws.Server({ + server: server +}); +var sessions = new SessionManager(); + +wss.on('connection', function(ws) { + // Get session ID + var sessionId = url.parse(ws.upgradeReq.url).pathname.substr(1); + + /** + * Handle the initial message (authentication) + * Once this client is authenticated, assign it to a session. + */ + ws.on('message', function initMsgHandler(msg) { + try { + var msgObj = JSON.parse(msg); + if (msgObj.type !== 'authenticate') { + throw new Error('Not authenticated.'); + } + + // Authenticate + if (!msgObj.token) { + throw new Error('No token is specified.'); + } + var user = jwt.verify(msgObj.token, JWT_SECRET); + + // Give the control to a session + sessions.addConnection(sessionId, ws, msgObj.clientId, user.username).then(function() { + ws.removeListener('message', initMsgHandler); + ws.send(JSON.stringify({ type: 'authenticated' })); + }).catch(function(err) { + ws.send(JSON.stringify({ type: 'error', error: err })); + }); + } catch (ex) { + ws.send(JSON.stringify({ + type: 'error', + message: ex.message + })); + } + }); +}); + +app.get('/', function(req, res, next) { + res.statusCode = 200; + res.write('OK'); + res.end(); +}); + +app.use(function(req, res, next) { + res.statusCode = 404; + res.write('Not found'); + res.end(); +}); + +app.use(function(err, req, res, next) { + res.statusCode = 500; + res.write('Internal error'); + console.error(err); + res.end(); +}); + +var host = process.env.HOST || '0.0.0.0'; +var port = process.env.PORT || 8082; + +server.listen(port, host, function () { + console.log('Collab Socket server is running on ' + host + ':' + port + '.'); +}); diff --git a/modules/orionode.collab.hub/session.js b/modules/orionode.collab.hub/session.js new file mode 100644 index 0000000000..86300fbd3b --- /dev/null +++ b/modules/orionode.collab.hub/session.js @@ -0,0 +1,318 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +'use strict'; + +var Document = require('./document.js'); +var ot = require('ot'); +var parseUrl = require('url').parse; + +/** + * This class record some connection status + */ +class ConnectionStatus { + constructor() { + this.created = Date.now(); + this.sample = []; + this.domains = {}; + this.urls = {}; + this.firstDomain = null; + this.totalMessageChars = 0; + this.totalMessages = 0; + this.connections = 0; + } +}; + +/** +* This class defines an active session. +* It includes the list of connections, list of active documents, etc. +*/ +class Session { + + constructor(sessionId) { + /** @type {Map.} */ + this.clients = new Map(); + this.connectionStats = new ConnectionStatus(); + /** @type {Object.} */ + this.docs = {}; + this.sessionId = sessionId; + } + + /** + * Add a connection + * + * @param {WebSocket} c + * @param {Client} client + */ + connectionJoined(c, client) { + this.clients.set(c, client); + this.connectionStats.connections++; + this.notifyAll(c, { + type: 'client-joined', + clientId: client.clientId, + name: client.name, + color: client.color + }); + } + + /** + * Remove a connection + * + * @param {WebSocket} c + * @param {function} callback - calls when it's done, with a boolean + * parameter indicates whether there is no conenctions left. + */ + connectionLeft(c, callback) { + var self = this; + var client = this.clients.get(c); + if (client) { + this.clients.delete(c); + + this.notifyAll(c, { + type: 'client-left', + clientId: client.clientId + }); + + // remove the user from the document + var doc = client.doc; + if (doc && this.docs[doc]) { + // check with the document if this is the last user. If so, clear the doc from memory. + this.docs[doc].leaveDocument(c, client.clientId, function(lastPerson) { + if (lastPerson) { + self.docs[doc].destroy(); + delete self.docs[doc]; + } + callback(!self.clients.size); + }); + } else { + callback(!self.clients.size); + } + } + } + + /** + * Handles incoming message + * + * @param {WebSocket} c + * @param {Object} msg + */ + onmessage(c, msg) { + var client = this.clients.get(c); + var self = this; + + if (msg.type === 'ping') { + c.send(JSON.stringify({ + type: 'pong' + })); + return; + } + + // if its a doc specific message, only send it to the clients involved. Otherwise send to all. + if (msg.doc) { + if (msg.type === 'join-document') { + this.joinDocument(c, msg, client, msg.doc); + client.doc = msg.doc; + } else { + var doc = this.docs[msg.doc]; + if (doc) { + doc.onmessage(c, msg, client); + } else { + c.send(JSON.stringify({ + type: 'error', + error: 'Invalid document ' + msg.doc + })); + } + } + } else { + if (msg.type === 'leave-document') { + if (client.doc && this.docs[client.doc]) { + this.leaveDocument(c, msg, client); + } + } else if (msg.type === 'update-client') { + if (msg.name) { + client.name = msg.name; + } + if (msg.color) { + client.color = msg.color; + } + if (msg.location !== undefined) { + client.location = msg.location; + } + if (msg.editing !== undefined) { + client.editing = msg.editing; + } + var outMsg = client.serialize(); + outMsg.type = 'client-updated'; + this.notifyAll(c, outMsg); + } else if (msg.type === 'get-clients') { + this.clients.forEach(function(peerClient) { + var outMsg = peerClient.serialize(); + outMsg.type = 'client-joined'; + c.send(JSON.stringify(outMsg)); + }); + } else if (msg.type === 'file-operation') { + var outMsg = { + type: 'file-operation', + clientId: client.clientId, + guid: msg.guid + } + // Check type + msg.data = Array.isArray(msg.data) ? msg.data.slice() : []; + outMsg.data = msg.data; + // Need to be careful to deal with renaming and deleting + // because the hub server might save a file after it is deleted + try { + if (msg.operation === 'created') { + outMsg.operation = 'created'; + this.notifyAll(c, outMsg); + } else if (msg.operation === 'moved') { + outMsg.operation = 'moved'; + var promises = []; + msg.data.forEach(function(file) { + promises.push(new Promise(function(resolve, reject) { + var from = self.convertWorkspacePathToProject(file.source); + var to = self.convertWorkspacePathToProject(file.result.Location); + if (self.docs[from] && !self.docs[from].discard) { + self.docs[from].saveDocument(to).then(function() { + self.docs[from].discard = true; + self.docs[from].destroy(); + delete self.docs[from]; + resolve(); + }).catch(function() { + resolve(); + }); + } else { + resolve(); + } + })); + }); + Promise.all(promises).then(function() { + self.notifyAll(c, outMsg); + }); + } else if (msg.operation === 'deleted') { + outMsg.operation = 'deleted'; + msg.data.forEach(function(file) { + var from = self.convertWorkspacePathToProject(file.deleteLocation); + if (self.docs[from] && !self.docs[from].discard) { + self.docs[from].discard = true; + self.docs[from].destroy(); + delete self.docs[from]; + } + }); + this.notifyAll(c, outMsg); + } else { + c.send(JSON.stringify({ + type: 'error', + error: 'Invalid file operation: ' + msg.operation + })); + } + } catch (ex) { + c.send(JSON.stringify({ + type: 'error', + error: 'Invalid operation.' + })); + console.error(ex); + } + } else { + c.send(JSON.stringify({ + type: 'error', + error: 'Unknown message type: ' + msg.type + })); + } + } + } + + /** + * Join a document + * + * @param {WebSocket} c + * @param {Object} msg + * @param {Client} client + * @param {string} doc + */ + joinDocument(c, msg, client, doc) { + if (client.doc && this.docs[client.doc]) { + this.leaveDocument(c, msg, client); + } + // if we don't have the document, let's start it up. + if (!this.docs[doc]) { + var self = this; + this.docs[doc] = new Document(doc, this.sessionId); + this.docs[doc].startOT() + .then(function() { + self.docs[doc].onmessage(c, msg, client); + }); + } else { + this.docs[doc].onmessage(c, msg, client); + } + } + + /** + * Leave the client's document + * + * @param {WebSocket} c + * @param {Object} msg + * @param {Client} client + */ + leaveDocument(c, msg, client) { + var self = this; + var doc = client.doc; + this.docs[doc].leaveDocument(c, msg.clientId, function(lastPerson) { + if (lastPerson) { + self.docs[doc].destroy(); + delete self.docs[doc]; + } + }); + client.doc = ''; + } + + /** + * Send message to all clients + * + * @param {WebSocket} c + * @param {Object} msg + * @param {boolean} [includeSender=false] + */ + notifyAll(c, msg, includeSender) { + includeSender = !!includeSender; + var msgStr = JSON.stringify(msg); + this.clients.forEach(function(client, conn) { + if (conn === c && !includeSender) { + return; + } + try { + conn.send(msgStr); + } catch (ex) { + console.error(ex); + } + }); + } + + /** + * Convert a path from workspace to project relative path + * + * @example + * convertWorkspacePathToProject('/file/myProj/myFile.txt') === 'myProj/myFile.txt'; + * + * @param {string} path + * + * @return {string} + */ + convertWorkspacePathToProject(path) { + if (path.indexOf('/file/') === 0) { + return path.substr(6); + } else { + return path.split('/').slice(7).join('/'); + } + } +} + +module.exports = Session; \ No newline at end of file diff --git a/modules/orionode.collab.hub/session_manager.js b/modules/orionode.collab.hub/session_manager.js new file mode 100644 index 0000000000..299999410a --- /dev/null +++ b/modules/orionode.collab.hub/session_manager.js @@ -0,0 +1,139 @@ +/******************************************************************************* + * @license + * Copyright (c) 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: IBM Corporation - initial API and implementation + ******************************************************************************/ + +'use strict'; + +var Client = require('./client'); +var config = require('./config'); +var jwt = require('jsonwebtoken'); +var Request = require('request'); +var Session = require('./session'); + +var CHECK_SESSION_URL = config.orion + config.checkSessionUrl; + +/** + * Manage sessions and their entering and leaving connections + */ +class SessionManager { + + constructor() { + /** + * A map from session ID to session instance + * @type {Object.} + */ + this._sessions = {}; + /** + * A map from session ID to a list of promise functions + * @type {Object.} + */ + this._sessionWaitingClients = {}; + } + + /** + * Add a connection to session + * + * @param {string} sessionId + * @param {WebSocket} ws + * @param {string} clientId + * @param {string} name + * + * @throws {SessionNotFoundError} + * + * @return {Promise} + */ + addConnection(sessionId, ws, clientId, name) { + var self = this; + return new Promise(function(resolve, reject) { + var session = self._sessions[sessionId]; + if (!session) { + if (self._sessionWaitingClients[sessionId]) { + // This session is initializing + self._sessionWaitingClients[sessionId].push({ resolve: resolve, reject, reject }); + } else { + // Initialize this session + self._sessionWaitingClients[sessionId] = []; + self._sessionWaitingClients[sessionId].push({ resolve: resolve, reject, reject }); + // Check existence + Request({ + uri: CHECK_SESSION_URL + sessionId, + headers: { + Authorization: 'Bearer ' + jwt.sign({}, config.jwt_secret) + } + }, function(err, response, body) { + if (err || response.statusCode === 404) { + self._sessionWaitingClients[sessionId].forEach(function(deferred) { + deferred.reject('Invalid session ID.'); + }); + delete self._sessionWaitingClients[sessionId]; + } else { + // Add new session + session = new Session(sessionId); + self._sessions[sessionId] = session; + self.addConnectionToSession(session, ws, new Client(clientId, name)); + self._sessionWaitingClients[sessionId].forEach(function(deferred) { + deferred.resolve(); + }); + delete self._sessionWaitingClients[sessionId]; + } + }); + } + } else { + self.addConnectionToSession(session, ws, new Client(clientId, name)); + resolve(); + } + }); + } + + /** + * Add a connection to an existing session + * + * @param {Session} session + * @param {WebSocket} ws + * @param {Client} client + */ + addConnectionToSession(session, ws, client) { + var self = this; + session.connectionJoined(ws, client); + + ws.on('message', function(msg) { + var msgObj; + try { + msgObj = JSON.parse(msg); + } catch(ex) { + ws.send(JSON.stringify({ + type: 'error', + error: 'Invalid JSON.' + })); + return; + } + session.onmessage(ws, msgObj); + }); + + ws.on('close', function(msg) { + session.connectionLeft(ws, function(empty) { + if (empty) { + delete self._sessions[session.sessionId]; + } + }); + }); + } +} + +/** + * Error for non-existing session + */ +class SessionNotFoundError extends Error { + constructor(sessionId) { + super('Session ID ' + sessionId + ' doesn\'t exist'); + } +} + +module.exports = SessionManager; diff --git a/modules/orionode/index.js b/modules/orionode/index.js index 3fcd8bdde3..ea3894d6db 100755 --- a/modules/orionode/index.js +++ b/modules/orionode/index.js @@ -68,9 +68,8 @@ function startServer(options) { var additionalEndpoints = require(options.configParams["additional.endpoint"]); additionalEndpoints.forEach(function(additionalEndpoint){ if(additionalEndpoint.endpoint){ - additionalEndpoint.authenticated ? - app.use(additionalEndpoint.endpoint, checkAuthenticated, require(additionalEndpoint.module).router(options)) : - app.use(additionalEndpoint.endpoint, require(additionalEndpoint.module).router(options)); + additionalEndpoint.authenticated ? app.use(additionalEndpoint.endpoint, checkAuthenticated, require(additionalEndpoint.module).router(options)) + : app.use(additionalEndpoint.endpoint, require(additionalEndpoint.module).router(options, additionalEndpoint.extraOptions)); }else{ var extraModule = require(additionalEndpoint.module); var middleware = extraModule.router ? extraModule.router(options) : extraModule(options); @@ -88,7 +87,7 @@ function startServer(options) { app.use('/gitapi', checkAuthenticated, require('./lib/git')({ gitRoot: contextPath + '/gitapi', fileRoot: /*contextPath + */'/file', workspaceRoot: /*contextPath + */'/workspace', options: options})); app.use('/cfapi', checkAuthenticated, require('./lib/cf')({ fileRoot: contextPath + '/file', options: options})); app.use('/prefs', checkAuthenticated, require('./lib/controllers/prefs').router(options)); - app.use('/xfer', checkAuthenticated, require('./lib/xfer')({fileRoot: contextPath + '/file', options:options})); + app.use('/xfer', checkAuthenticated, require('./lib/xfer').router({fileRoot: contextPath + '/file', options:options})); app.use('/metrics', require('./lib/metrics').router(options)); app.use('/version', require('./lib/version').router(options)); if (options.configParams.isElectron) app.use('/update', require('./lib/update').router(options)); diff --git a/modules/orionode/lib/shared/db/sharedProjects.js b/modules/orionode/lib/shared/db/sharedProjects.js new file mode 100644 index 0000000000..408103e8bd --- /dev/null +++ b/modules/orionode/lib/shared/db/sharedProjects.js @@ -0,0 +1,287 @@ +/******************************************************************************* + * Copyright (c) 2016, 2017 IBM Corporation and others. + * All rights reserved. This program and the accompanying materials are made + * available under the terms of the Eclipse Public License v1.0 + * (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution + * License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html). + * + * Contributors: + * IBM Corporation - initial API and implementation + *******************************************************************************/ +/*eslint-env node*/ + +var express = require('express'), + expressSession = require('express-session'), + MongoStore = require('connect-mongo')(expressSession), + passport = require('passport'), + cookieParser = require('cookie-parser'), + bodyParser = require('body-parser'), + mongoose = require('mongoose'), + Promise = require('bluebird'), + fs = require('fs'), + args = require('../../args'); + +function projectJSON(project) { + return { + Location: project.projectpath, + HubID: project.hubid, + Owner: project.username, + Users: project.users + }; +} + +mongoose.Promise = Promise; + +module.exports = function(options) { + var workspaceRoot = options.options.workspaceDir; + if (!workspaceRoot) { throw new Error('options.options.workspaceDir path required'); } + + var sharedUtil = require('../sharedUtil'); + var path = require('path'); + var userProjectsCollection = require('./userProjects'); + + var app = express.Router(); + module.exports.getHubID = getHubID; + module.exports.getProjectPathFromHubID = getProjectPathFromHubID; + module.exports.getProjectRoot = getProjectRoot; + module.exports.addUserToProject = addUserToProject; + module.exports.removeUserFromProject = removeUserFromProject; + module.exports.getUsersInProject = getUsersInProject; + + var sharedProjectsSchema = new mongoose.Schema({ + location: { + type: String, + unique: true, + required: true + }, + hubid: { + type: String, + unique: true, + required: true + }, + owner: { + type: String + }, + users: [String] + }); + + var sharedProject = mongoose.model('sharedProject', sharedProjectsSchema); + + app.use(bodyParser.json()); + app.use(bodyParser.urlencoded({ extended: false })); + app.use(cookieParser()); + app.use(expressSession({ + resave: false, + saveUninitialized: false, + secret: 'keyboard cat', + store: new MongoStore({ mongooseConnection: mongoose.connection }) + })); + + /**START OF HELPER FUNCTIONS**/ + + /** + * Returns true if the user owns the project, + * and therefore is allowed to share/unshare it. + * + * Not necessary since we append the user workspaceroot before taking project root. + */ + function isProjectOwner(user, projectpath) { + //TODO + return false; + } + + /** + * Adds the project and a new hubID to the sharedProjects db document. + */ + function addProject(project) { + var hub = generateUniqueHubId(); + //TODO Also add name of project owner? Replace by projectJSON all over the file. + var query = sharedProject.findOne({location: project}); + return query.exec() + .then(function(doc) { + return doc ? Promise.resolve(doc) : sharedProject.create({location: project, hubid: hub}); + }); + } + + /** + * Removes project from shared projects. + * Also removes all references from the other table. + */ + function removeProject(project) { + return sharedProject.findOne({'location': project}).exec() + .then(function(doc) { + if (doc.users.length > 0) { + return userProjectsCollection.removeProjectReferences(doc.users, project).exec(); + } + }) + .then(function() { + return sharedProject.remove({location: project}).exec(); + }); + } + + /** + * For example if the project is renamed. + */ + function updateProject(projectpath, data) { + //TODO + return false; + } + + /** + * returns a unique hubID + */ + function generateUniqueHubId() { + //TODO ensure generated hub id is unique (not in db) + // do { + var length = 10; + var letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV0123456789'; + var s = ''; + for (var i=0; i Date: Wed, 12 Apr 2017 12:18:51 -0400 Subject: [PATCH 02/37] Fix xfer test --- modules/orionode/test/test-xfer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/orionode/test/test-xfer.js b/modules/orionode/test/test-xfer.js index 051070e501..882a237a56 100644 --- a/modules/orionode/test/test-xfer.js +++ b/modules/orionode/test/test-xfer.js @@ -23,7 +23,7 @@ var WORKSPACE_ID = "orionode"; var app = express(); app.locals.metastore = require('../lib/metastore/fs/store')({workspaceDir: WORKSPACE}); app.locals.metastore.setup(app); -app.use(CONTEXT_PATH + '/xfer', require("../lib/xfer")({ fileRoot: CONTEXT_PATH + "/xfer" })); +app.use(CONTEXT_PATH + '/xfer', require("../lib/xfer").router({ fileRoot: CONTEXT_PATH + "/xfer" })); var request = supertest.bind(null, app); From 823c4feea8df799ffcb5c26183b9013eac1e7e7c Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 26 May 2017 14:36:43 -0400 Subject: [PATCH 03/37] collab configuration Change-Id: Iff1e6864a41d212be9be3758d9cb761749b818bd Signed-off-by: Sidney --- modules/orionode/endpoint.json | 8 ++++++++ modules/orionode/orion.conf | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 modules/orionode/endpoint.json diff --git a/modules/orionode/endpoint.json b/modules/orionode/endpoint.json new file mode 100644 index 0000000000..d8ffc8e1a1 --- /dev/null +++ b/modules/orionode/endpoint.json @@ -0,0 +1,8 @@ +[{ + "endpoint": "/sharedWorkspace", + "module": "./lib/sharedWorkspace", + "extraOptions": { + "root": "/sharedWorkspace/tree/file", + "fileRoot": "/file" + } +}] \ No newline at end of file diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 6f5664d66a..bfafd2907b 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -17,9 +17,9 @@ orion.oauth.github.client= orion.oauth.github.secret= orion.oauth.google.client= orion.oauth.google.secret= -orion.jwt.secret= +orion.jwt.secret=orion collab -orion.single.user=true +orion.single.user=false orion.buildId= orion.autoUpdater.url= @@ -47,13 +47,13 @@ mail.from= prepend.static.assets= #serve these static assets after the original default ones(will not overwrite original ones, but might be overwritten), relative to orion_static.js -append.static.assets= +append.static.assets=./bundles/org.eclipse.orion.client.collab/web #defines where cf get bearer token from cf.bearer.token.store= #specify the path of the json file which defines additional endpoints -additional.endpoint= +additional.endpoint=./endpoint.json #serve additional modules additional.modules.path= From c207f1a755ffde822d7539f193b7d8425ce0d200 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 26 May 2017 14:40:06 -0400 Subject: [PATCH 04/37] forgot this Change-Id: I83531797197b8a039bea03215bdb3e03964738bb Signed-off-by: Sidney --- bundles/org.eclipse.orion.client.ui/web/defaults.pref | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index 122d31be54..600cb78a31 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -10,6 +10,7 @@ "edit/content/imageViewerPlugin.html":true, "edit/content/jsonEditorPlugin.html":true, "cfui/plugins/cFPlugin.html":true, - "cfui/plugins/cFDeployPlugin.html":true + "cfui/plugins/cFDeployPlugin.html":true, + "collab/plugins/collabPlugin.html": true } } From 19d17cd74a27bfd5fdb4e2bf4dfdddfe2c9cd459 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 2 Jun 2017 12:14:48 -0400 Subject: [PATCH 05/37] fix out of multi workspace implementation, part 1 Change-Id: I016f921e36743eab80b5706eac4e01317838fb44 Signed-off-by: Sidney --- .../web/orion/collab/collabFileCommands.js | 4 +- .../web/orion/edit/nls/root/messages.js | 3 +- modules/orionode/lib/fileUtil.js | 2 +- .../orionode/lib/shared/db/sharedProjects.js | 17 ++---- .../orionode/lib/shared/db/userProjects.js | 19 +++--- .../orionode/lib/shared/sharedDecorator.js | 60 ++++++++++--------- modules/orionode/lib/shared/sharedUtil.js | 4 -- modules/orionode/lib/shared/tree.js | 43 ++++++------- modules/orionode/lib/sharedWorkspace.js | 4 +- modules/orionode/lib/workspace.js | 2 +- 10 files changed, 74 insertions(+), 84 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js index e2ef7615f1..40399b1545 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileCommands.js @@ -37,7 +37,7 @@ define(['orion/collab/shareProjectClient'], function(shareProjectClient) { parameters: new mCommandRegistry.ParametersDescription([new mCommandRegistry.CommandParameter('username', 'text', "Username:", "Enter username here")]), //$NON-NLS-5$ //$NON-NLS-4$ //$NON-NLS-3$ //$NON-NLS-2$ //$NON-NLS-1$ //$NON-NLS-0$ callback: function(data) { var username = data.parameters.parameterTable.username.value; - var project = data.items[0].Name; + var project = encodeURIComponent(data.items[0].Location); shareProjectClient.addUser(username, project); }, visibleWhen: function(item) { @@ -60,7 +60,7 @@ define(['orion/collab/shareProjectClient'], function(shareProjectClient) { parameters: new mCommandRegistry.ParametersDescription([new mCommandRegistry.CommandParameter('username', 'text', "Username:", "Enter username here")]), //$NON-NLS-5$ //$NON-NLS-4$ //$NON-NLS-3$ //$NON-NLS-2$ //$NON-NLS-1$ //$NON-NLS-0$ callback: function(data) { var username = data.parameters.parameterTable.username.value; - var project = data.items[0].Name; + var project = encodeURIComponent(data.items[0].Location); shareProjectClient.removeUser(username, project); }, visibleWhen: function(item) { diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js b/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js index 1e9c0544d1..f5e5333a21 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/edit/nls/root/messages.js @@ -121,8 +121,7 @@ define({ "selectPreviousTab": "Select Previous Editor Tab", "showTabDropdown": "Display Open Editor Tabs", "Collaborate": "Collaborate", - "CollaborateToolTip": "Start a Collaboration session on the current file" - "showTabDropdown": "Display Open Editor Tabs", + "CollaborateToolTip": "Start a Collaboration session on the current file", "closeOthers":"Close Others Tabs", "closeTotheRight":"Close Tabs To The Right", "keepOpen":"Keep Open", diff --git a/modules/orionode/lib/fileUtil.js b/modules/orionode/lib/fileUtil.js index b0ab3f32c5..b42c3ce24d 100644 --- a/modules/orionode/lib/fileUtil.js +++ b/modules/orionode/lib/fileUtil.js @@ -335,7 +335,7 @@ var writeFileMetadata = exports.writeFileMetadata = function(req, res, fileRoot, result.ETag = etag; res.setHeader('ETag', etag); } - api.writeResponse(null, res, null, result, true, true); + return api.writeResponse(null, res, null, result, true, true); }) .catch(api.writeError.bind(null, 500, res)); }; diff --git a/modules/orionode/lib/shared/db/sharedProjects.js b/modules/orionode/lib/shared/db/sharedProjects.js index 408103e8bd..9ac49e17ee 100644 --- a/modules/orionode/lib/shared/db/sharedProjects.js +++ b/modules/orionode/lib/shared/db/sharedProjects.js @@ -13,13 +13,10 @@ var express = require('express'), expressSession = require('express-session'), MongoStore = require('connect-mongo')(expressSession), - passport = require('passport'), cookieParser = require('cookie-parser'), bodyParser = require('body-parser'), mongoose = require('mongoose'), - Promise = require('bluebird'), - fs = require('fs'), - args = require('../../args'); + Promise = require('bluebird'); function projectJSON(project) { return { @@ -244,14 +241,12 @@ module.exports = function(options) { */ app.post('/:project', function(req, res) { var project = req.params.project; - project = path.join(workspaceRoot, req.user.workspace, project); + project = path.join("/",req.user._doc.workspace,project); - if (!sharedUtil.projectExists(project)) { + if (!sharedUtil.projectExists(path.join(req.user.workspaceDir, project))) { throw new Error("Project does not exist"); } - project = getProjectRoot(project); - //if add project was successful, return addProject(project) .then(function(result) { @@ -264,14 +259,12 @@ module.exports = function(options) { */ app.delete('/:project', function(req, res) { var project = req.params.project; - project = path.join(workspaceRoot, req.user.workspace, project); + project = path.join("/",req.user._doc.workspace,project); - if (!sharedUtil.projectExists(project)) { + if (!sharedUtil.projectExists(path.join(req.user.workspaceDir, project))) { throw new Error("Project does not exist"); } - project = getProjectRoot(project); - //if remove project was successful, return 200 removeProject(project) .then(function(result) { diff --git a/modules/orionode/lib/shared/db/userProjects.js b/modules/orionode/lib/shared/db/userProjects.js index 3d2cafd89c..3fc1dab63e 100644 --- a/modules/orionode/lib/shared/db/userProjects.js +++ b/modules/orionode/lib/shared/db/userProjects.js @@ -16,9 +16,8 @@ var express = require('express'), cookieParser = require('cookie-parser'), bodyParser = require('body-parser'), mongoose = require('mongoose'), - Promise = require('bluebird'), - fs = require('fs'), - args = require('../../args'); + fileUtil= require('../../fileUtil'), + Promise = require('bluebird'); function userProjectJSON(username) { return { @@ -137,22 +136,21 @@ module.exports = function(options) { */ app.post('/:project/:user', function(req, res) { //TODO make sure project has been shared first. - var project = req.params.project; + var project = fileUtil.getFile(req, decodeURIComponent(req.params.project).substring(5)); var user = req.params.user; - project = path.join(workspaceRoot, req.user.workspace, project); - if (!sharedUtil.projectExists(project)) { + if (!sharedUtil.projectExists(project.path)) { throw new Error("Project does not exist"); } - project = projectsCollection.getProjectRoot(project); + project = projectsCollection.getProjectRoot(project.path); projectsCollection.addUserToProject(user, project) .then(function(doc) { return addProjectToUser(user, project); }) .then(function(result) { - res.end(); + return res.end(); }) .catch(function(err){ // just need one of these @@ -166,10 +164,9 @@ module.exports = function(options) { * Project might have been deleted or just user removed from shared list. */ app.delete('/:project/:user', function(req, res) { - var project = req.params.project; + var project = fileUtil.getFile(req, decodeURIComponent(req.params.project).substring(5)); var user = req.params.user; - project = path.join(workspaceRoot, req.user.workspace, project); - project = projectsCollection.getProjectRoot(project); + project = projectsCollection.getProjectRoot(project.path); projectsCollection.removeUserFromProject(user, project) .then(function() { diff --git a/modules/orionode/lib/shared/sharedDecorator.js b/modules/orionode/lib/shared/sharedDecorator.js index c8796cfa8b..c49c147817 100644 --- a/modules/orionode/lib/shared/sharedDecorator.js +++ b/modules/orionode/lib/shared/sharedDecorator.js @@ -9,35 +9,39 @@ * IBM Corporation - initial API and implementation *******************************************************************************/ /*eslint-env node */ -var api = require('../api'); -var sharedUtil = require('./sharedUtil'); var sharedProjects = require('./db/sharedProjects'); module.exports = {}; - -module.exports.sharedDecorator = function(contextPath, rootName, req, filepath, originalJson){ - var result = originalJson; - if (!"/file" === rootName && !"/workspace" ===rootName) {//$NON-NLS-1$ //$NON-NLS-2$ - return; - } - var isWorkspace = "/workspace"=== rootName; - if (isWorkspace) { - return; - } - if (!isWorkspace && req.method === "GET") { - return Promise.resolve(sharedProjects.getHubID(filepath)) - .then(function(hubID) { - if(hubID){ - return addHubMetadata(result,hubID); - } - }) - .catch(function(){ - return; - }); - } +module.exports.SharedFileDecorator = SharedFileDecorator; - function addHubMetadata(comingJson,hub){ - comingJson["Attributes"].hubID = hub; - return comingJson; - } -}; +function SharedFileDecorator(options) { + this.options = options; +} +Object.assign(SharedFileDecorator.prototype, { + decorate: function(req, file, json) { + var contextPath = req.contextPath || ""; + var endpoint = req.originalUrl.substring(contextPath.length).split("/")[1]; + if (!"file" === endpoint && !"workspace" === endpoint) {//$NON-NLS-1$ //$NON-NLS-2$ + return; + } + var isWorkspace = "workspace" === endpoint; + if (isWorkspace) { + return; + } + if (!isWorkspace && req.method === "GET") { + return Promise.resolve(sharedProjects.getHubID(file.path)) + .then(function(hubID) { + if(hubID){ + return addHubMetadata(json, hubID); + } + }) + .catch(function(){ + return; + }); + } + } +}); +function addHubMetadata(comingJson,hub){ + comingJson["Attributes"].hubID = hub; + return comingJson; +} \ No newline at end of file diff --git a/modules/orionode/lib/shared/sharedUtil.js b/modules/orionode/lib/shared/sharedUtil.js index 53ac691fbe..1ecacaa191 100644 --- a/modules/orionode/lib/shared/sharedUtil.js +++ b/modules/orionode/lib/shared/sharedUtil.js @@ -11,14 +11,10 @@ /*eslint-env node */ /*eslint no-console:1*/ var api = require('../api'), writeError = api.writeError; -var url = require("url"); var path = require("path"); var fs = require('fs'); var fileUtil = require('../fileUtil'); -var express = require('express'); -var bodyParser = require('body-parser'); var Promise = require('bluebird'); -var sharedProjects = require('./db/sharedProjects') var userProjects = require('./db/userProjects'); module.exports = function(options) { diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index 8e7f5034e1..fd0fbfeffa 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -144,22 +144,23 @@ module.exports.router = function(options) { * For file save. */ function putFile(req, res) { - var filepath = fileUtil.safeFilePath(workspaceRoot, req.params["0"]); - var fileRoot = req.params["0"]; + var rest = req.params["0"]; + var file = fileUtil.getFile(req, rest); + var fileRoot = options.fileRoot; if (req.params['parts'] === 'meta') { // TODO implement put of file attributes res.sendStatus(501); return; } function write() { - var ws = fs.createWriteStream(filepath); + var ws = fs.createWriteStream(file.path); ws.on('finish', function() { - fileUtil.withStatsAndETag(filepath, function(error, stats, etag) { + fileUtil.withStatsAndETag(file.path, function(error, stats, etag) { if (error && error.code === 'ENOENT') { res.status(404).end(); return; } - writeFileMetadata(fileRoot, req, res, filepath, stats, etag); + writeFileMetadata(fileRoot, req, res, file.path, stats, etag); }); }); ws.on('error', function(err) { @@ -206,9 +207,9 @@ module.exports.router = function(options) { * For file delete. */ function deleteFile(req, res) { - var rest = req.params["0"].substring(1); - var filepath = fileUtil.safeFilePath(workspaceRoot, rest); - fileUtil.withStatsAndETag(filepath, function(error, stats, etag) { + var rest = req.params["0"]; + var file = fileUtil.getFile(req, rest); + fileUtil.withStatsAndETag(file.path, function(error, stats, etag) { var callback = function(error) { if (error) { writeError(500, res, error); @@ -224,9 +225,9 @@ module.exports.router = function(options) { } else if (ifMatchHeader && ifMatchHeader !== etag) { return res.sendStatus(412); } else if (stats.isDirectory()) { - fileUtil.rumRuff(filepath, callback); + fileUtil.rumRuff(file.path, callback); } else { - fs.unlink(filepath, callback); + fs.unlink(file.path, callback); } }); } @@ -321,11 +322,10 @@ module.exports.router = function(options) { if (!filepath) { writeError(404, res, 'Session not found: ' + hubid); return; - } else { - res.write('{}'); - res.end(); - return; - } + } + res.write('{}'); + res.end(); + return; }); } @@ -333,13 +333,14 @@ module.exports.router = function(options) { * Export */ function getXfer(req, res) { - var filePath = req.params["0"]; + var rest = req.params["0"]; + var file = fileUtil.getFile(req, rest); - if (path.extname(filePath) !== ".zip") { + if (path.extname(file.path) !== ".zip") { return writeError(400, res, "Export is not a zip"); } - filePath = fileUtil.safeFilePath(workspaceRoot, filePath.replace(/.zip$/, "")); + var filePath = file.path.replace(/.zip$/, ""); xfer.getXferFrom(req, res, filePath); } @@ -347,8 +348,8 @@ module.exports.router = function(options) { * Import */ function postImportXfer(req, res) { - var filePath = req.params["0"]; - filePath = fileUtil.safeFilePath(workspaceRoot, filePath); - xfer.postImportXferTo(req, res, filePath); + var rest = req.params["0"]; + var file = fileUtil.getFile(req, rest); + xfer.postImportXferTo(req, res, file.path); } }; diff --git a/modules/orionode/lib/sharedWorkspace.js b/modules/orionode/lib/sharedWorkspace.js index a8e07c1d1d..29da709da4 100644 --- a/modules/orionode/lib/sharedWorkspace.js +++ b/modules/orionode/lib/sharedWorkspace.js @@ -13,7 +13,7 @@ var fileUtil = require('./fileUtil'); var express = require('express'); var tree = require('./shared/tree'); var sharedUtil = require('./shared/sharedUtil'); -var sharedDecorator = require('./shared/sharedDecorator').sharedDecorator; +var SharedFileDecorator = require('./shared/sharedDecorator').SharedFileDecorator; var jwt = require('jsonwebtoken'); module.exports.router = function(options, extraOptions) { @@ -60,7 +60,7 @@ module.exports.router = function(options, extraOptions) { router.use("/tree", tree.router(extraOptions)); router.use("/project", require('./shared/db/sharedProjects')(extraOptions)); router.use("/user", require('./shared/db/userProjects')(extraOptions)); - fileUtil.addDecorator(sharedDecorator); + fileUtil.addDecorator(new SharedFileDecorator(options)); sharedUtil(extraOptions); return [checkCollabAuthenticated, router]; } diff --git a/modules/orionode/lib/workspace.js b/modules/orionode/lib/workspace.js index dda4633328..3fa43ba79f 100644 --- a/modules/orionode/lib/workspace.js +++ b/modules/orionode/lib/workspace.js @@ -111,7 +111,7 @@ module.exports = function(options) { return writeError(singleUser ? 403 : 400, res, err); } getWorkspaceJson(req, workspace).then(function(workspaceJson) { - api.writeResponse(null, res, null, workspaceJson, true); + return api.writeResponse(null, res, null, workspaceJson, true); }).catch(function(err) { api.writeResponse(400, res, null, err); }); From 8271eb3614e4aa368442aafaf72f9c580c1a9ff3 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 2 Jun 2017 15:52:59 -0400 Subject: [PATCH 06/37] more fix Signed-off-by: Sidney --- .../web/orion/collab/collabFileImpl.js | 85 ++++++++++++++++--- modules/orionode/lib/shared/tree.js | 66 +++++++++----- 2 files changed, 120 insertions(+), 31 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js index 9d48941954..c7388238f1 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js @@ -17,9 +17,27 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio this.fileBase = fileBase; } + function makeAbsolute(loc) { + return new URL(loc, self.location.href).href; + } + + function _normalizeLocations(data) { + if (data && typeof data === "object") { + Object.keys(data).forEach(function(key) { + var value = data[key]; + if (key.indexOf("Location") !== -1) { + data[key] = makeAbsolute(value); + } else { + _normalizeLocations(value); + } + }); + } + return data; + } var GIT_TIMEOUT = 60000; CollabFileImpl.prototype = { + fetchChildren: function(location) { var fetchLocation = location; if (fetchLocation===this.fileBase) { @@ -41,24 +59,69 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio }); }, loadWorkspaces: function() { - return this.loadWorkspace(this._repoURL); - }, - loadWorkspace: function(location) { + var loc = this.fileBase; var suffix = "/sharedWorkspace/"; - if (location && location.indexOf(suffix, location.length - suffix.length) !== -1) { - location += "tree/"; + if (loc && loc.indexOf(suffix, loc.length - suffix.length) !== -1) { + loc += "/tree"; } - - return xhr("GET", location,{ //$NON-NLS-0$ + return xhr("GET", loc, { headers: { - "Orion-Version": "1", //$NON-NLS-0$ //$NON-NLS-1$ - "Content-Type": "charset=UTF-8" //$NON-NLS-0$ //$NON-NLS-1$ + "Orion-Version": "1" }, timeout: GIT_TIMEOUT }).then(function(result) { var jsonData = result.response ? JSON.parse(result.response) : {}; - return jsonData || {}; - }); + return jsonData.Workspaces; + }).then(function(result) { + if (this.makeAbsolute) { + _normalizeLocations(result); + } + return result; + }.bind(this)); + }, + loadWorkspace: function(loc) { + var suffix = "/sharedWorkspace/"; + if (loc && loc.indexOf(suffix, loc.length - suffix.length) !== -1) { + loc += "/tree"; + } + return xhr("GET", loc, { + headers: { + "Orion-Version": "1" + }, + timeout: GIT_TIMEOUT, + log: false + }).then(function(result) { + var jsonData = result.response ? JSON.parse(result.response) : {}; + //in most cases the returned object is the workspace we care about + //user didn't specify a workspace so we are at the root + //just pick the first location in the provided list + if (jsonData.Workspaces && jsonData.Workspaces.length > 0) { + return this.loadWorkspace(jsonData.Workspaces[0].Location); + } + return jsonData; + }.bind(this)).then(function(result) { + if (this.makeAbsolute) { + _normalizeLocations(result); + } + return result; + }.bind(this)); + }, + getWorkspace: function(resourceLocation) { + //TODO move this to server to avoid path math? + var id = resourceLocation || ""; + if (id.indexOf(this.fileBase) === 0) id = id.substring(this.fileBase.length); + id = id.split("/"); + if (id.length > 2 && (id[1] === "file")) id = id[2]; + return this.loadWorkspaces().then(function(workspaces) { + var loc = ""; + workspaces.some(function(workspace) { + if (workspace.Id === id) { + loc = workspace.Location; + return true; + } + }); + return this.loadWorkspace(loc); + }.bind(this)); }, createProject: function(url, projectName, serverPath, create) { throw new Error("Not supported"); //$NON-NLS-0$ diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index fd0fbfeffa..68b5b4cce9 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -31,7 +31,7 @@ module.exports.router = function(options) { return express.Router() .get('/', getSharedWorkspace) - .get('/file*', ensureAccess, getTree) + .get('/file*', getTree) .put('/file*', ensureAccess, putFile) .post('/file*', ensureAccess, postFile) .delete('/file*', ensureAccess, deleteFile) @@ -45,25 +45,24 @@ module.exports.router = function(options) { * Get shared projects for the user. */ function getSharedWorkspace(req, res) { + var sharedWorkspaceRoot = "/sharedWorkspace" + "/tree" + options.fileRoot + if (!req.params[0]) { + api.writeResponse(null, res, null, { + Id: req.user.username, + Name: req.user.username, + UserName: req.user.fullname || req.user.username, + Workspaces: req.user.workspaces.map(function(w) { + return { + Id: w.id, + Location: api.join(sharedWorkspaceRoot, w.id), + Name: w.name + }; + }) + }, true); + return; + } //if its the base call, return all Projects that are shared with the user - return sharedUtil.getSharedProjects(req, res, function(projects) { - var tree = sharedUtil.treeJSON("/", "", 0, true, 0, false); - var children = tree.Children = []; - function add(projects) { - projects.forEach(function(project) { - children.push(sharedUtil.treeJSON(project.Name, project.Location, 0, true, 0, false)); - if (project.Children) add(project.Children); - }); - } - add(projects, tree); - tree["Projects"] = children.map(function(c) { - return { - Id: c.Name, - Location: c.Location, - }; - }); - res.status(200).json(tree); - }); + } function ensureAccess(req, res, next) { @@ -85,6 +84,33 @@ module.exports.router = function(options) { * return files and folders below current folder or retrieve file contents. */ function getTree(req, res) { + var segmentCount = req.params["0"].split("/").length; + if (segmentCount < 2) { + writeError(409, res); + return; + } + + if (segmentCount === 2) { + return sharedUtil.getSharedProjects(req, res, function(projects) { + var tree = sharedUtil.treeJSON("/", "", 0, true, 0, false); + var children = tree.Children = []; + function add(projects) { + projects.forEach(function(project) { + children.push(sharedUtil.treeJSON(project.Name, project.Location, 0, true, 0, false)); + if (project.Children) add(project.Children); + }); + } + add(projects, tree); + tree["Projects"] = children.map(function(c) { + return { + Id: c.Name, + Location: c.Location, + }; + }); + res.status(200).json(tree); + }); + } + var tree; var filePath = fileUtil.safeFilePath(workspaceRoot, req.params["0"]); var fileRoot = req.params["0"]; @@ -118,7 +144,7 @@ module.exports.router = function(options) { tree.Attributes = {}; tree["Attributes"].hubID = hub; } - res.status(200).json(tree); + return res.status(200).json(tree); }) .catch(api.writeError.bind(null, 500, res)); } else if (stats.isFile()) { From f42c450511e098d1e6cd6c46109c17513425191a Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 6 Jun 2017 16:43:32 -0400 Subject: [PATCH 07/37] now sharedworkspaces are multi workspace based Signed-off-by: Sidney --- .../web/orion/collab/collabFileImpl.js | 36 ++-- .../web/edit/setup.js | 3 +- modules/orionode/lib/shared/tree.js | 184 ++++++++++-------- 3 files changed, 119 insertions(+), 104 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js index c7388238f1..47e263a5ad 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js @@ -41,7 +41,11 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio fetchChildren: function(location) { var fetchLocation = location; if (fetchLocation===this.fileBase) { - return new Deferred().resolve([]); + return this.loadWorkspaces().then(function(workspaces) { + return Deferred.all(workspaces.map(function(workspace) { + return this.read(workspace.Location, true); + }.bind(this))); + }.bind(this)); } //If fetch location does not have ?depth=, then we need to add the depth parameter. Otherwise server will not return any children if (fetchLocation.indexOf("?depth=") === -1) { //$NON-NLS-0$ @@ -58,7 +62,8 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio return jsonData.Children || []; }); }, - loadWorkspaces: function() { + loadWorkspaces: function(loc) { + // For sharedworkspace, there's only one workspace. var loc = this.fileBase; var suffix = "/sharedWorkspace/"; if (loc && loc.indexOf(suffix, loc.length - suffix.length) !== -1) { @@ -72,11 +77,6 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio }).then(function(result) { var jsonData = result.response ? JSON.parse(result.response) : {}; return jsonData.Workspaces; - }).then(function(result) { - if (this.makeAbsolute) { - _normalizeLocations(result); - } - return result; }.bind(this)); }, loadWorkspace: function(loc) { @@ -86,25 +86,14 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio } return xhr("GET", loc, { headers: { - "Orion-Version": "1" + "Orion-Version": "1", + "Content-Type": "charset=UTF-8" }, - timeout: GIT_TIMEOUT, - log: false + timeout: GIT_TIMEOUT }).then(function(result) { var jsonData = result.response ? JSON.parse(result.response) : {}; - //in most cases the returned object is the workspace we care about - //user didn't specify a workspace so we are at the root - //just pick the first location in the provided list - if (jsonData.Workspaces && jsonData.Workspaces.length > 0) { - return this.loadWorkspace(jsonData.Workspaces[0].Location); - } - return jsonData; - }.bind(this)).then(function(result) { - if (this.makeAbsolute) { - _normalizeLocations(result); - } - return result; - }.bind(this)); + return jsonData || {}; + }); }, getWorkspace: function(resourceLocation) { //TODO move this to server to avoid path math? @@ -122,6 +111,7 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio }); return this.loadWorkspace(loc); }.bind(this)); +// return this.loadWorkspace(resourceLocation); }, createProject: function(url, projectName, serverPath, create) { throw new Error("Not supported"); //$NON-NLS-0$ diff --git a/bundles/org.eclipse.orion.client.ui/web/edit/setup.js b/bundles/org.eclipse.orion.client.ui/web/edit/setup.js index f3a73be520..a4cd9f0b58 100644 --- a/bundles/org.eclipse.orion.client.ui/web/edit/setup.js +++ b/bundles/org.eclipse.orion.client.ui/web/edit/setup.js @@ -1539,7 +1539,8 @@ objects.mixin(EditorSetup.prototype, { return true; } })) { - window.location = uriTemplate.expand({resource: evt.newInput}); + window.location = uriTemplate.expand({resource: workspaces[0].Location}); +// window.location = uriTemplate.expand({resource: evt.newInput}); } }); }.bind(this)); diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index 68b5b4cce9..a9d68dace9 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -28,10 +28,11 @@ module.exports.router = function(options) { if (!workspaceRoot) { throw new Error('options.options.workspaceDir required'); } var xfer = require('../xfer'); var contextPath = options.options.configParams["orion.context.path"] || ""; + var SEPARATOR = "-"; return express.Router() - .get('/', getSharedWorkspace) - .get('/file*', getTree) + .get('/file/:WorkspaceId*', getTree) + .get('/*', getSharedWorkspace) .put('/file*', ensureAccess, putFile) .post('/file*', ensureAccess, postFile) .delete('/file*', ensureAccess, deleteFile) @@ -45,24 +46,45 @@ module.exports.router = function(options) { * Get shared projects for the user. */ function getSharedWorkspace(req, res) { - var sharedWorkspaceRoot = "/sharedWorkspace" + "/tree" + options.fileRoot - if (!req.params[0]) { - api.writeResponse(null, res, null, { + return sharedUtil.getSharedProjects(req, res, function(projects) { + var workspaces = []; + var sharedWorkspaceRoot = "/sharedWorkspace" + "/tree" + options.fileRoot; + projects.forEach(function(project){ + var projectSegs = project.Location.split("/"); + var projectBelongingWorkspaceId = projectSegs[2] + SEPARATOR + projectSegs[3]; + if(!workspaces.some(function(workspace){ + return workspace.Id === projectBelongingWorkspaceId; + })){ + workspaces.push({ + Id: projectBelongingWorkspaceId, + Location: api.join(sharedWorkspaceRoot, projectBelongingWorkspaceId), + Name: projectSegs[3] + }); + } + }); + api.writeResponse(null, res, null, { Id: req.user.username, Name: req.user.username, UserName: req.user.fullname || req.user.username, - Workspaces: req.user.workspaces.map(function(w) { - return { - Id: w.id, - Location: api.join(sharedWorkspaceRoot, w.id), - Name: w.name - }; - }) + Workspaces: workspaces }, true); - return; - } - //if its the base call, return all Projects that are shared with the user - + }); + } + + function getfileRoot(workspaceId) { + var userId = decodeUserIdFromWorkspaceId(workspaceId); + return path.join(userId.substring(0,2), userId, decodeWorkspaceNameFromWorkspaceId(workspaceId)); + } + function decodeUserIdFromWorkspaceId(workspaceId) { + var index = workspaceId.lastIndexOf(SEPARATOR); + if (index === -1) return null; + return workspaceId.substring(0, index); + } + + function decodeWorkspaceNameFromWorkspaceId(workspaceId) { + var index = workspaceId.lastIndexOf(SEPARATOR); + if (index === -1) return null; + return workspaceId.substring(index + 1); } function ensureAccess(req, res, next) { @@ -84,20 +106,75 @@ module.exports.router = function(options) { * return files and folders below current folder or retrieve file contents. */ function getTree(req, res) { - var segmentCount = req.params["0"].split("/").length; - if (segmentCount < 2) { - writeError(409, res); - return; - } - - if (segmentCount === 2) { + var projectName = req.params["0"]; + var workspaceId = req.params.WorkspaceId; + if(projectName){ + var tree; + var fileRoot =path.join("/", workspaceId, req.params["0"]); + var realfileRootPath = getfileRoot(workspaceId); + var filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); + var readIfExists = req.headers ? Boolean(req.headers['read-if-exists']).valueOf() : false; + fileUtil.withStatsAndETag(filePath, function(err, stats, etag) { + if (err && err.code === 'ENOENT') { + if(typeof readIfExists === 'boolean' && readIfExists) { + res.sendStatus(204); + } else { + writeError(404, res, 'File not found: ' + filePath); + } + } else if (err) { + writeError(500, res, err); + } else if (stats.isDirectory()) { + sharedUtil.getChildren(fileRoot, filePath, req.query.depth ? req.query.depth: 1) + .then(function(children) { + // TODO this is basically a File object with 1 more field. Should unify the JSON between workspace.js and file.js + children.forEach(function(child) { + child.Id = child.Name; + }); + location = fileRoot; + var name = path.win32.basename(filePath); + tree = sharedUtil.treeJSON(name, location, 0, true, 0, false); + tree["Children"] = children; + }) + .then(function() { + return sharedProjects.getHubID(filePath); + }) + .then(function(hub){ + if (hub) { + tree.Attributes = {}; + tree["Attributes"].hubID = hub; + } + return res.status(200).json(tree); + }) + .catch(api.writeError.bind(null, 500, res)); + } else if (stats.isFile()) { + if (req.query.parts === "meta") { + var name = path.win32.basename(filePath); + var result = sharedUtil.treeJSON(name, fileRoot, 0, false, 0, false); + result.ETag = etag; + sharedProjects.getHubID(filePath) + .then(function(hub){ + if (hub) { + result["Attributes"].hubID = hub; + } + return res.status(200).json(result); + }); + } else { + sharedUtil.getFile(res, filePath, stats, etag); + } + } + }); + }else{ return sharedUtil.getSharedProjects(req, res, function(projects) { - var tree = sharedUtil.treeJSON("/", "", 0, true, 0, false); + var tree = sharedUtil.treeJSON(req.params.WorkspaceId, "/" + workspaceId, 0, true, 0, false); var children = tree.Children = []; function add(projects) { projects.forEach(function(project) { - children.push(sharedUtil.treeJSON(project.Name, project.Location, 0, true, 0, false)); - if (project.Children) add(project.Children); + var projectSegs = project.Location.split("/"); + var projectBelongingWorkspaceId = projectSegs[2] + SEPARATOR + projectSegs[3]; + if(projectBelongingWorkspaceId === workspaceId){ + children.push(sharedUtil.treeJSON(project.Name, path.join("/", projectBelongingWorkspaceId, projectSegs[4]), 0, true, 0, false)); + if (project.Children) add(project.Children); + } }); } add(projects, tree); @@ -110,60 +187,7 @@ module.exports.router = function(options) { res.status(200).json(tree); }); } - - var tree; - var filePath = fileUtil.safeFilePath(workspaceRoot, req.params["0"]); - var fileRoot = req.params["0"]; - var readIfExists = req.headers ? Boolean(req.headers['read-if-exists']).valueOf() : false; - fileUtil.withStatsAndETag(filePath, function(err, stats, etag) { - if (err && err.code === 'ENOENT') { - if(typeof readIfExists === 'boolean' && readIfExists) { - res.sendStatus(204); - } else { - writeError(404, res, 'File not found: ' + filePath); - } - } else if (err) { - writeError(500, res, err); - } else if (stats.isDirectory()) { - sharedUtil.getChildren(fileRoot, filePath, req.query.depth ? req.query.depth: 1) - .then(function(children) { - // TODO this is basically a File object with 1 more field. Should unify the JSON between workspace.js and file.js - children.forEach(function(child) { - child.Id = child.Name; - }); - location = fileRoot; - var name = path.win32.basename(filePath); - tree = sharedUtil.treeJSON(name, location, 0, true, 0, false); - tree["Children"] = children; - }) - .then(function() { - return sharedProjects.getHubID(filePath); - }) - .then(function(hub){ - if (hub) { - tree.Attributes = {}; - tree["Attributes"].hubID = hub; - } - return res.status(200).json(tree); - }) - .catch(api.writeError.bind(null, 500, res)); - } else if (stats.isFile()) { - if (req.query.parts === "meta") { - var name = path.win32.basename(filePath); - var result = sharedUtil.treeJSON(name, fileRoot, 0, false, 0, false); - result.ETag = etag; - sharedProjects.getHubID(filePath) - .then(function(hub){ - if (hub) { - result["Attributes"].hubID = hub; - } - return res.status(200).json(result); - }); - } else { - sharedUtil.getFile(res, filePath, stats, etag); - } - } - }); + } /** From 626bfdd16254ff0f4bf8e6cf1621c9f2a10b5679 Mon Sep 17 00:00:00 2001 From: Sidney Date: Wed, 7 Jun 2017 10:54:22 -0400 Subject: [PATCH 08/37] load first sharedworkspace if loc is sharedworkspace/tree Signed-off-by: Sidney --- .../web/orion/collab/collabFileImpl.js | 33 +++++++++++-------- .../web/edit/setup.js | 3 +- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js index 47e263a5ad..8722e70e97 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js @@ -34,7 +34,7 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio } return data; } - var GIT_TIMEOUT = 60000; + var COLLAB_TIMEOUT = 60000; CollabFileImpl.prototype = { @@ -56,7 +56,7 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio "Orion-Version": "1", //$NON-NLS-0$ //$NON-NLS-1$ "Content-Type": "charset=UTF-8" //$NON-NLS-0$ //$NON-NLS-1$ }, - timeout: GIT_TIMEOUT + timeout: COLLAB_TIMEOUT }).then(function(result) { var jsonData = result.response ? JSON.parse(result.response) : {}; return jsonData.Children || []; @@ -73,27 +73,33 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio headers: { "Orion-Version": "1" }, - timeout: GIT_TIMEOUT + timeout: COLLAB_TIMEOUT }).then(function(result) { var jsonData = result.response ? JSON.parse(result.response) : {}; return jsonData.Workspaces; }.bind(this)); }, loadWorkspace: function(loc) { - var suffix = "/sharedWorkspace/"; - if (loc && loc.indexOf(suffix, loc.length - suffix.length) !== -1) { - loc += "/tree"; + if (loc === this.fileBase) { + loc = null; } - return xhr("GET", loc, { + return xhr("GET", loc ? loc : this.fileBase, { headers: { - "Orion-Version": "1", - "Content-Type": "charset=UTF-8" + "Orion-Version": "1" }, - timeout: GIT_TIMEOUT + timeout: COLLAB_TIMEOUT }).then(function(result) { var jsonData = result.response ? JSON.parse(result.response) : {}; - return jsonData || {}; - }); + //in most cases the returned object is the workspace we care about + if (loc) { + return jsonData; + } + //user didn't specify a workspace so we are at the root + //just pick the first location in the provided list + if (jsonData.Workspaces.length > 0) { + return this.loadWorkspace(jsonData.Workspaces[0].Location); + } + }.bind(this)); }, getWorkspace: function(resourceLocation) { //TODO move this to server to avoid path math? @@ -111,7 +117,6 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio }); return this.loadWorkspace(loc); }.bind(this)); -// return this.loadWorkspace(resourceLocation); }, createProject: function(url, projectName, serverPath, create) { throw new Error("Not supported"); //$NON-NLS-0$ @@ -302,7 +307,7 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio readBlob: function(location) { return xhr("GET", location, { //$NON-NLS-0$ responseType: "arraybuffer", //$NON-NLS-0$ - timeout: GIT_TIMEOUT + timeout: COLLAB_TIMEOUT }).then(function(result) { return result.response; }); diff --git a/bundles/org.eclipse.orion.client.ui/web/edit/setup.js b/bundles/org.eclipse.orion.client.ui/web/edit/setup.js index a4cd9f0b58..f3a73be520 100644 --- a/bundles/org.eclipse.orion.client.ui/web/edit/setup.js +++ b/bundles/org.eclipse.orion.client.ui/web/edit/setup.js @@ -1539,8 +1539,7 @@ objects.mixin(EditorSetup.prototype, { return true; } })) { - window.location = uriTemplate.expand({resource: workspaces[0].Location}); -// window.location = uriTemplate.expand({resource: evt.newInput}); + window.location = uriTemplate.expand({resource: evt.newInput}); } }); }.bind(this)); From f262ed6e27a7cd2bf842b77f28cf05ec381e859d Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 13 Jun 2017 15:00:56 -0400 Subject: [PATCH 09/37] more fix --- modules/orionode.collab.hub/config.js | 4 +- .../orionode/lib/shared/db/sharedProjects.js | 17 +------ .../orionode/lib/shared/db/userProjects.js | 4 +- modules/orionode/lib/shared/sharedUtil.js | 46 +++++++++++++++++++ modules/orionode/lib/shared/tree.js | 34 ++++---------- 5 files changed, 59 insertions(+), 46 deletions(-) diff --git a/modules/orionode.collab.hub/config.js b/modules/orionode.collab.hub/config.js index 99b9891de8..94bf66e6c1 100644 --- a/modules/orionode.collab.hub/config.js +++ b/modules/orionode.collab.hub/config.js @@ -13,11 +13,11 @@ module.exports = { /** * jwt_secret needs to be the same as the one in the server connected to the filesystem (orion) */ - 'jwt_secret': "pomato (potato and tomato mix lol)", + 'jwt_secret': "orion collab", /** * Orion url */ - 'orion': "http://localhost:8081/", + 'orion': "http://localhost:8084/", /** * Load url. Make sure end with / */ diff --git a/modules/orionode/lib/shared/db/sharedProjects.js b/modules/orionode/lib/shared/db/sharedProjects.js index 9ac49e17ee..7e6295376e 100644 --- a/modules/orionode/lib/shared/db/sharedProjects.js +++ b/modules/orionode/lib/shared/db/sharedProjects.js @@ -40,7 +40,6 @@ module.exports = function(options) { var app = express.Router(); module.exports.getHubID = getHubID; module.exports.getProjectPathFromHubID = getProjectPathFromHubID; - module.exports.getProjectRoot = getProjectRoot; module.exports.addUserToProject = addUserToProject; module.exports.removeUserFromProject = removeUserFromProject; module.exports.getUsersInProject = getUsersInProject; @@ -142,7 +141,7 @@ module.exports = function(options) { } function getHubID(filepath) { - var project = getProjectRoot(filepath); + var project = sharedUtil.getProjectRoot(filepath); var query = sharedProject.findOne({ location: project, @@ -166,20 +165,6 @@ module.exports = function(options) { }); } - /** - * Removes the root server workspace, and trailing file tree within project. - * Example: - * input: "C:\Users\IBM_ADMIN\node.workspace\mo\mourad\OrionContent\web\hello.html" - * return: "\mo\mourad\OrionContent\web" which is the unique project path format that can be found in the database. - */ - function getProjectRoot(filepath) { - var index = filepath.indexOf(workspaceRoot); - if (index === -1) return undefined; - index += workspaceRoot.length; - filepath = filepath.substring(index); - return filepath.split(path.sep).slice(0,5).join(path.sep); - } - /** * Returns the list of users that the project is shared with. */ diff --git a/modules/orionode/lib/shared/db/userProjects.js b/modules/orionode/lib/shared/db/userProjects.js index 3fc1dab63e..dbc0c7b78b 100644 --- a/modules/orionode/lib/shared/db/userProjects.js +++ b/modules/orionode/lib/shared/db/userProjects.js @@ -143,7 +143,7 @@ module.exports = function(options) { throw new Error("Project does not exist"); } - project = projectsCollection.getProjectRoot(project.path); + project = sharedUtil.getProjectRoot(project.path); projectsCollection.addUserToProject(user, project) .then(function(doc) { @@ -166,7 +166,7 @@ module.exports = function(options) { app.delete('/:project/:user', function(req, res) { var project = fileUtil.getFile(req, decodeURIComponent(req.params.project).substring(5)); var user = req.params.user; - project = projectsCollection.getProjectRoot(project.path); + project = sharedUtil.getProjectRoot(project.path); projectsCollection.removeUserFromProject(user, project) .then(function() { diff --git a/modules/orionode/lib/shared/sharedUtil.js b/modules/orionode/lib/shared/sharedUtil.js index 1ecacaa191..4317895172 100644 --- a/modules/orionode/lib/shared/sharedUtil.js +++ b/modules/orionode/lib/shared/sharedUtil.js @@ -18,6 +18,7 @@ var Promise = require('bluebird'); var userProjects = require('./db/userProjects'); module.exports = function(options) { + var SEPARATOR = "-"; var workspaceRoot = options.options.workspaceDir; if (!workspaceRoot) { throw new Error('options.options.workspaceDir path required'); } @@ -28,6 +29,11 @@ module.exports = function(options) { module.exports.getChildren = getChildren; module.exports.getSharedProjects = getSharedProjects; module.exports.projectExists = projectExists; + module.exports.getfileRoot = getfileRoot; + module.exports.decodeUserIdFromWorkspaceId = decodeUserIdFromWorkspaceId; + module.exports.decodeWorkspaceNameFromWorkspaceId = decodeWorkspaceNameFromWorkspaceId; + module.exports.getProjectRoot = getProjectRoot; + module.exports.getProjectLocationFromWorkspaceId = getProjectLocationFromWorkspaceId; function projectExists(fullpath) { return fs.existsSync(fullpath); @@ -135,4 +141,44 @@ module.exports = function(options) { callback(projects); }); } + + function getfileRoot(workspaceId) { + var userId = decodeUserIdFromWorkspaceId(workspaceId); + return path.join(userId.substring(0,2), userId, decodeWorkspaceNameFromWorkspaceId(workspaceId)); + } + function decodeUserIdFromWorkspaceId(workspaceId) { + var index = workspaceId.lastIndexOf(SEPARATOR); + if (index === -1) return null; + return workspaceId.substring(0, index); + } + + function decodeWorkspaceNameFromWorkspaceId(workspaceId) { + var index = workspaceId.lastIndexOf(SEPARATOR); + if (index === -1) return null; + return workspaceId.substring(index + 1); + } + + /** + * Removes the root server workspace, and trailing file tree within project. + * Example: + * input: "C:\Users\IBM_ADMIN\node.workspace\mo\mourad\OrionContent\web\hello.html" + * return: "\mo\mourad\OrionContent\web" which is the unique project path format that can be found in the database. + */ + function getProjectRoot(filepath) { + var index = filepath.indexOf(workspaceRoot); + if (index === -1) return undefined; + index += workspaceRoot.length; + filepath = filepath.substring(index); + return filepath.split(path.sep).slice(0,5).join(path.sep); + } + + /** + * /user1-orionContent/test/testfile -> /us/user1/orionContent/test + */ + function getProjectLocationFromWorkspaceId(filepath) { + var segs = filepath.split(path.sep); + var fileRoot = getfileRoot(segs[1]); + var realPath = path.join("/", fileRoot, segs[2]); + return realPath; + } } \ No newline at end of file diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index a9d68dace9..11c115b98c 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -10,15 +10,13 @@ *******************************************************************************/ /*eslint-env node */ var api = require('../api'), writeError = api.writeError; -var git = require('nodegit'); var path = require('path'); var express = require('express'); -var mime = require('mime'); var sharedUtil = require('./sharedUtil'); var fileUtil = require('../fileUtil'); +var ofile = require('../file'); var fs = require('fs'); var sharedProjects = require('./db/sharedProjects'); -var writeError = api.writeError; module.exports = {}; @@ -32,7 +30,6 @@ module.exports.router = function(options) { return express.Router() .get('/file/:WorkspaceId*', getTree) - .get('/*', getSharedWorkspace) .put('/file*', ensureAccess, putFile) .post('/file*', ensureAccess, postFile) .delete('/file*', ensureAccess, deleteFile) @@ -40,8 +37,9 @@ module.exports.router = function(options) { .put('/save/:hubId/*', saveFile) .get('/session/:hubId', checkSession) .get('/xfer/export*', getXfer) - .post('/xfer/import*', postImportXfer); - + .post('/xfer/import*', postImportXfer) + .get('/*', getSharedWorkspace); + /** * Get shared projects for the user. */ @@ -70,25 +68,9 @@ module.exports.router = function(options) { }, true); }); } - - function getfileRoot(workspaceId) { - var userId = decodeUserIdFromWorkspaceId(workspaceId); - return path.join(userId.substring(0,2), userId, decodeWorkspaceNameFromWorkspaceId(workspaceId)); - } - function decodeUserIdFromWorkspaceId(workspaceId) { - var index = workspaceId.lastIndexOf(SEPARATOR); - if (index === -1) return null; - return workspaceId.substring(0, index); - } - - function decodeWorkspaceNameFromWorkspaceId(workspaceId) { - var index = workspaceId.lastIndexOf(SEPARATOR); - if (index === -1) return null; - return workspaceId.substring(index + 1); - } function ensureAccess(req, res, next) { - var project = sharedProjects.getProjectRoot(path.join(workspaceRoot, req.params["0"])); + var project = sharedUtil.getProjectLocationFromWorkspaceId(req.params["0"]); var username = req.user.username; sharedProjects.getUsersInProject(project) @@ -111,7 +93,7 @@ module.exports.router = function(options) { if(projectName){ var tree; var fileRoot =path.join("/", workspaceId, req.params["0"]); - var realfileRootPath = getfileRoot(workspaceId); + var realfileRootPath = sharedUtil.getfileRoot(workspaceId); var filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); var readIfExists = req.headers ? Boolean(req.headers['read-if-exists']).valueOf() : false; fileUtil.withStatsAndETag(filePath, function(err, stats, etag) { @@ -219,7 +201,7 @@ module.exports.router = function(options) { req.pipe(ws); } var ifMatchHeader = req.headers['if-match']; - fileUtil.withETag(filepath, function(error, etag) { + fileUtil.withETag(file.path, function(error, etag) { if (error && error.code === 'ENOENT') { res.status(404).end(); } @@ -292,7 +274,7 @@ module.exports.router = function(options) { res.setHeader('ETag', etag); } res.setHeader("Cache-Control", "no-cache"); - api.write(null, res, null, result); + api.writeResponse(null, res, null, result, true, true); }) .catch(api.writeError.bind(null, 500, res)); }; From 9d31a2d5de8f22a5acdb49d00bde3083ea83ea23 Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 13 Jun 2017 15:01:26 -0400 Subject: [PATCH 10/37] more Signed-off-by: Sidney --- bundles/org.eclipse.orion.client.ui/web/defaults.pref | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index 600cb78a31..c7b41e958b 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -12,5 +12,6 @@ "cfui/plugins/cFPlugin.html":true, "cfui/plugins/cFDeployPlugin.html":true, "collab/plugins/collabPlugin.html": true - } -} + }, + "/collab": { "hubUrl": "ws://localhost:8082/" } +} \ No newline at end of file From 33f4e1ea01c9d59c0fc70c56d51a89e39e2ad473 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 15 Jun 2017 11:30:47 -0400 Subject: [PATCH 11/37] fixes Signed-off-by: Sidney --- .../web/orion/collab/collabClient.js | 17 ++--- .../web/orion/explorers/explorer-table.js | 63 ++++++++++++++++--- modules/orionode/lib/shared/tree.js | 16 +++-- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js index 3c2e7dc499..df08d693c6 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js @@ -371,9 +371,10 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot var workspace = this.getFileSystemPrefix(); if (workspace !== '/file/') { //get everything after 'workspace name' - return this.location.substring(this.location.indexOf(workspace) + workspace.length).split('/').slice(3).join('/'); + // /sharedWorkspace/tree/file/ji/jiangxin87/OrionContent/orders-api-uselessTesting_X/app.js . -> orders-api-uselessTesting_X/app.js + return this.location.substring(this.location.indexOf(workspace) + workspace.length).split('/').slice(1).join('/'); } else { - return this.location.substring(this.location.indexOf(workspace) + workspace.length, this.location.length); + return this.location.split("/").slice(3).join("/"); } }, @@ -571,7 +572,7 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot * For example we potentially need to convert a '/file/web/potato.js' to '/sharedWorkspace/tree/file/web/potato.js' * and vice-versa, depending on our file system and the sender's filesystem. */ - maybeTransformLocation: function(Location) { + maybeTransformLocation: function(Location) { //Location = "/file/orders-api-uselessTesting_X/app.js" var loc = this.getFileSystemPrefix(); // if in same workspace if (Location.substr(contextPath.length).indexOf(loc) === 0) { @@ -580,10 +581,10 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot var oppositeLoc = loc == '/file/' ? '/sharedWorkspace/tree/file/' : '/file/'; // we need to replace sharedWorkspace... with /file and vice versa. // we also need to replace workspace info for shared workspace or add it when its not the case. - var file = this.projectRelativeLocation(Location); - var currFile = this.projectRelativeLocation(location.hash.substr(1)); - var prefix = location.hash.substr(1, location.hash.lastIndexOf(currFile) - 1); - Location = prefix + file; + var file = this.projectRelativeLocation(Location); // orders-api-uselessTesting_X/app.js + var currFile = this.projectRelativeLocation(location.hash.substr(1)); // currFile = "orders-api-uselessTesting_X/app.js" + var prefix = location.hash.substr(1, location.hash.lastIndexOf(currFile) - 1); // prefix = "/sharedWorkspace/tree/file/ji/jiangxin87/OrionContent/" + Location = prefix + file; // Location = "/sharedWorkspace/tree/file/ji/jiangxin87/OrionContent/orders-api-uselessTesting_X/app.js" return Location; } }, @@ -594,7 +595,7 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot return location.substr((contextPath + '/file/').length); } else { // Shared workspace - return location.substr(contextPath.length).split('/').slice(7).join('/'); + return location.substr(contextPath.length).split('/').slice(5).join('/'); } }, diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js index e4fa017bfb..9c3076bf1b 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js @@ -53,10 +53,19 @@ define([ this.filteredResources = filteredResources; } this.filter = new FileFilter(this.filteredResources); + this._annotations = []; + // Listen to annotation changed events + this._annotationChangedHandler = this._handleAnnotationChanged.bind(this) + this.fileClient.addEventListener('AnnotationChanged', this._annotationChangedHandler); } FileModel.prototype = new mExplorer.ExplorerModel(); objects.mixin(FileModel.prototype, /** @lends orion.explorer.FileModel.prototype */ { - getRoot: function(onItem) { + destroy: function () { + removeEventListener(this._annotationChangedHandler, this._annotationChangedHandler); + mExplorer.ExplorerModel.prototype.destroy.call(this); + }, + + getRoot: function(onItem){ onItem(this.root); }, @@ -152,6 +161,28 @@ define([ result = this.root.Children.length > 0; } return result; + }, + + getAnnotations: function() { + return this._annotations; + }, + + _handleAnnotationChanged: function(evt) { + var model = this; + if (evt.removeTypes) { + for (var i = this._annotations.length - 1; i >= 0; i--) { + for (var j = 0; j < evt.removeTypes.length; j++) { + if (this._annotations[i] instanceof evt.removeTypes[j]) { + this._annotations.splice(i, 1); + break; + } + } + } + } + console.assert(Array.isArray(evt.annotations)); + evt.annotations.forEach(function(annotation) { + model._annotations.push(annotation); + }); } }); @@ -274,10 +305,13 @@ define([ this.checkbox = false; this._hookedDrag = false; this.filteredResources = options.filteredResources; + this._annotations = []; var modelEventDispatcher = options.modelEventDispatcher ? options.modelEventDispatcher : new EventTarget(); this.modelEventDispatcher = modelEventDispatcher; - //Listen to all resource changed events + // Listen to all resource changed events this.fileClient.addEventListener("Changed", this._resourceChangedHandler = this.handleResourceChange.bind(this)); + // Listen to annotation changed events + this.fileClient.addEventListener('AnnotationChanged', this._annotationChangedHandler = this._handleAnnotationChanged.bind(this)); // Listen to model changes from fileCommands var _self = this; this._modelListeners = {}; @@ -355,6 +389,7 @@ define([ parentNode.removeEventListener("click", this._clickListener); } this.fileClient.removeEventListener("Changed", this._resourceChangedHandler); + this.fileClient.removeEventListener("AnnotationChanged", this._annotationChangedHandler); mExplorer.Explorer.prototype.destroy.call(this); }, _isFileCreationAtRootEnabled : function() { @@ -404,7 +439,8 @@ define([ type: "create", select: createdItem.eventData ? createdItem.eventData.select : false, newValue: createdItem.result, - parent: this._getUIModel(createdItem.parent) + parent: this._getUIModel(createdItem.parent), + silent: evt.silent }; }.bind(this)); return this.onModelCreate(items[0]).then(function( /*result*/ ) { @@ -419,7 +455,8 @@ define([ return { oldValue: uiModel, newValue: null, - parent: uiModel ? uiModel.parent : null + parent: uiModel ? uiModel.parent : null, + silent: evt.silent }; }.bind(this)); var newEvent = { @@ -436,7 +473,8 @@ define([ return { oldValue: this._getUIModel(movedItem.source), newValue: movedItem.result, - parent: this._getUIModel(movedItem.target) + parent: this._getUIModel(movedItem.target), + silent: evt.silent }; }.bind(this)); var newEvent = { @@ -464,8 +502,15 @@ define([ // onLinkClick: function(clickEvent) { // this.dispatchEvent(clickEvent); // }, + + _handleAnnotationChanged: function(evt) { + if (this.myTree) { + this.myTree.requestAnnotationRefresh(); + } + }, + onModelCreate: function(modelEvent) { - return this.changedItem(modelEvent.parent, true); + return this.changedItem(modelEvent.parent, !modelEvent.silent); }, onModelCopy: function(modelEvent) { var ex = this, @@ -476,7 +521,7 @@ define([ changedLocations[itemParent.Location] = itemParent; }); return Deferred.all(Object.keys(changedLocations).map(function(loc) { - return ex.changedItem(changedLocations[loc], true); + return ex.changedItem(changedLocations[loc], !modelEvent.silent); })); }, onModelMove: function(modelEvent) { @@ -513,7 +558,7 @@ define([ } }); return Deferred.all(Object.keys(changedLocations).map(function(loc) { - return ex.changedItem(changedLocations[loc], true); + return ex.changedItem(changedLocations[loc], !modelEvent.silent); })); }, onModelDelete: function(modelEvent) { @@ -545,7 +590,7 @@ define([ } }); return Deferred.all(Object.keys(changedLocations).map(function(loc) { - return ex.changedItem(changedLocations[loc], true); + return ex.changedItem(changedLocations[loc], !modelEvent.silent); })); }, diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index 11c115b98c..1e84295e5c 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -88,13 +88,17 @@ module.exports.router = function(options) { * return files and folders below current folder or retrieve file contents. */ function getTree(req, res) { - var projectName = req.params["0"]; - var workspaceId = req.params.WorkspaceId; + var projectName = req.params["0"]; // projectName = /test + var workspaceId = req.params.WorkspaceId; // workspaceId = sidney-OrionContent if(projectName){ - var tree; - var fileRoot =path.join("/", workspaceId, req.params["0"]); - var realfileRootPath = sharedUtil.getfileRoot(workspaceId); - var filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); + var tree, filePath; + if(workspaceId){ + var fileRoot =path.join("/", workspaceId, req.params["0"]); // fileRoot = /sidney-OrionContent/test + var realfileRootPath = sharedUtil.getfileRoot(workspaceId); // = si/sidney/OrionContent + filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); // "/Users/xinyijiang/IBM/openSourceWorkspace/orion.client/modules/orionode/.workspace/si/sidney/OrionContent/test" + }else{ + filePath = path.join(workspaceRoot, req.params["0"]); + } var readIfExists = req.headers ? Boolean(req.headers['read-if-exists']).valueOf() : false; fileUtil.withStatsAndETag(filePath, function(err, stats, etag) { if (err && err.code === 'ENOENT') { From 94e7902c4795faaadf31f43b8eefb2f467e86946 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 15 Jun 2017 15:00:33 -0400 Subject: [PATCH 12/37] ugly fixes, now at this point, everything works as demoed, next step is to refactor the code Signed-off-by: Sidney --- .../web/orion/collab/collabClient.js | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js index df08d693c6..02df398063 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js @@ -190,14 +190,14 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot * @param {boolean} editing */ addOrUpdateCollabFileAnnotation: function(clientId, url, editing) { - if (url) { - url = this.maybeTransformLocation(url); - } var peer = this.getPeer(clientId); // Peer might be loading. Once it is loaded, this annotation will be automatically updated, // so we can safely leave it blank. var name = (peer && peer.name) ? peer.name : 'Unknown'; var color = (peer && peer.color) ? peer.color : '#000000'; + if (url) { + url = this.maybeTransformLocation(url); + } this.collabFileAnnotations[clientId] = new CollabFileAnnotation(name, color, url, this.projectRelativeLocation(url), editing); this._requestFileAnnotationUpdate(); }, @@ -276,7 +276,7 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot */ updateSelfFileAnnotation: function() { if (this.collabMode) { - this.addOrUpdateCollabFileAnnotation(this.getClientId(), this.maybeTransformLocation(contextPath + '/file/' + this.currentDoc()), this.editing); + this.addOrUpdateCollabFileAnnotation(this.getClientId(), this.maybeTransformLocation(contextPath + '/file/' + this.currentDocPath()), this.editing); } }, @@ -377,6 +377,16 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot return this.location.split("/").slice(3).join("/"); } }, + + currentDocPath: function() { + var workspace = this.getFileSystemPrefix(); + if (workspace !== '/file/') { + return this.location.substring(this.location.indexOf(workspace) + workspace.length).split('/').slice(1).join('/'); + } else { + return this.location.split("/").slice(2).join("/"); + } + }, + getFileSystemPrefix: function() { return this.location.substr(contextPath.length).indexOf('/sharedWorkspace') === 0 ? '/sharedWorkspace/tree/file/' : '/file/'; @@ -576,7 +586,14 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot var loc = this.getFileSystemPrefix(); // if in same workspace if (Location.substr(contextPath.length).indexOf(loc) === 0) { - return Location; + var workspaceID = this.location.substr(contextPath.length + loc).split("/")[2]; + var result; + if(Location.indexOf(workspaceID) !== -1){ + result = Location; + }else{ + result = loc + workspaceID + Location.substr(loc.length -1 ); + } + return result; } else { var oppositeLoc = loc == '/file/' ? '/sharedWorkspace/tree/file/' : '/file/'; // we need to replace sharedWorkspace... with /file and vice versa. From c81fb19085b27b5e87a7d8a325fa0506c1ac1960 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 15 Jun 2017 16:53:04 -0400 Subject: [PATCH 13/37] cleaned 50% garbage code Signed-off-by: Sidney --- .../web/orion/collab/collabClient.js | 52 +++++-------------- .../web/orion/collab/otAdapters.js | 2 +- modules/orionode.collab.hub/document.js | 4 +- modules/orionode/lib/shared/tree.js | 30 ++++------- 4 files changed, 26 insertions(+), 62 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js index 02df398063..0313622def 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js @@ -276,7 +276,7 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot */ updateSelfFileAnnotation: function() { if (this.collabMode) { - this.addOrUpdateCollabFileAnnotation(this.getClientId(), this.maybeTransformLocation(contextPath + '/file/' + this.currentDocPath()), this.editing); + this.addOrUpdateCollabFileAnnotation(this.getClientId(), contextPath + this.currentDoc(), this.editing); } }, @@ -367,29 +367,16 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot } }, + /** + * peal /shardworkspace/tree/file/user-orionContent/testfolder/testfile of /file/user-orionContent/testfolder/testfile to /user-orionContent/testfolder/testfile + */ currentDoc: function() { var workspace = this.getFileSystemPrefix(); - if (workspace !== '/file/') { - //get everything after 'workspace name' - // /sharedWorkspace/tree/file/ji/jiangxin87/OrionContent/orders-api-uselessTesting_X/app.js . -> orders-api-uselessTesting_X/app.js - return this.location.substring(this.location.indexOf(workspace) + workspace.length).split('/').slice(1).join('/'); - } else { - return this.location.split("/").slice(3).join("/"); - } + return this.location.substring(this.location.indexOf(workspace) + workspace.length); }, - - currentDocPath: function() { - var workspace = this.getFileSystemPrefix(); - if (workspace !== '/file/') { - return this.location.substring(this.location.indexOf(workspace) + workspace.length).split('/').slice(1).join('/'); - } else { - return this.location.split("/").slice(2).join("/"); - } - }, - getFileSystemPrefix: function() { - return this.location.substr(contextPath.length).indexOf('/sharedWorkspace') === 0 ? '/sharedWorkspace/tree/file/' : '/file/'; + return this.location.substr(contextPath.length).indexOf('/sharedWorkspace') === 0 ? '/sharedWorkspace/tree/file' : '/file'; }, viewInstalled: function(event) { @@ -584,32 +571,17 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot */ maybeTransformLocation: function(Location) { //Location = "/file/orders-api-uselessTesting_X/app.js" var loc = this.getFileSystemPrefix(); - // if in same workspace - if (Location.substr(contextPath.length).indexOf(loc) === 0) { - var workspaceID = this.location.substr(contextPath.length + loc).split("/")[2]; - var result; - if(Location.indexOf(workspaceID) !== -1){ - result = Location; - }else{ - result = loc + workspaceID + Location.substr(loc.length -1 ); - } - return result; - } else { - var oppositeLoc = loc == '/file/' ? '/sharedWorkspace/tree/file/' : '/file/'; - // we need to replace sharedWorkspace... with /file and vice versa. - // we also need to replace workspace info for shared workspace or add it when its not the case. - var file = this.projectRelativeLocation(Location); // orders-api-uselessTesting_X/app.js - var currFile = this.projectRelativeLocation(location.hash.substr(1)); // currFile = "orders-api-uselessTesting_X/app.js" - var prefix = location.hash.substr(1, location.hash.lastIndexOf(currFile) - 1); // prefix = "/sharedWorkspace/tree/file/ji/jiangxin87/OrionContent/" - Location = prefix + file; // Location = "/sharedWorkspace/tree/file/ji/jiangxin87/OrionContent/orders-api-uselessTesting_X/app.js" - return Location; - } + return loc + Location; }, + + /* + * Generate the location string for the tooltip for each collabrator. + */ projectRelativeLocation: function(location) { if (location.substr(contextPath.length).indexOf('/file/') === 0) { // Local workspace - return location.substr((contextPath + '/file/').length); + return location.substr(contextPath.length).split("/").slice(3).join('/') } else { // Shared workspace return location.substr(contextPath.length).split('/').slice(5).join('/'); diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js index 40b38f1178..e7ecc0240a 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js @@ -286,7 +286,7 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function case 'client-updated': this.collabClient.addOrUpdatePeer(new CollabPeer(msg.clientId, msg.name, msg.color)); if (msg.location) { - this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + '/file/' + msg.location, msg.editing); + this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + msg.location, msg.editing); } else { this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, '', msg.editing); } diff --git a/modules/orionode.collab.hub/document.js b/modules/orionode.collab.hub/document.js index fca462f7a4..93eff22087 100644 --- a/modules/orionode.collab.hub/document.js +++ b/modules/orionode.collab.hub/document.js @@ -217,7 +217,7 @@ class Document { var self = this; return new Promise(function(resolve, reject) { Request({ - uri: FILE_LOAD_URL + self.sessionId + '/' + self.id, + uri: FILE_LOAD_URL + self.sessionId + self.id, headers: { Authorization: 'Bearer ' + SERVER_TOKEN } @@ -253,7 +253,7 @@ class Document { }; Request({ method: 'PUT', - uri: FILE_SAVE_URL + self.sessionId + '/' + path, + uri: FILE_SAVE_URL + self.sessionId + path, headers: headerData, body: self.ot.document }, function(error, response, body) { diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index 1e84295e5c..b50bde618e 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -29,11 +29,11 @@ module.exports.router = function(options) { var SEPARATOR = "-"; return express.Router() - .get('/file/:WorkspaceId*', getTree) + .get('/file/:workspaceId*', getTree) .put('/file*', ensureAccess, putFile) .post('/file*', ensureAccess, postFile) .delete('/file*', ensureAccess, deleteFile) - .get('/load/:hubId/*', loadFile) + .get('/load/:hubId/:workspaceId*', loadFile) .put('/save/:hubId/*', saveFile) .get('/session/:hubId', checkSession) .get('/xfer/export*', getXfer) @@ -89,16 +89,12 @@ module.exports.router = function(options) { */ function getTree(req, res) { var projectName = req.params["0"]; // projectName = /test - var workspaceId = req.params.WorkspaceId; // workspaceId = sidney-OrionContent + var workspaceId = req.params.workspaceId; // workspaceId = sidney-OrionContent if(projectName){ var tree, filePath; - if(workspaceId){ - var fileRoot =path.join("/", workspaceId, req.params["0"]); // fileRoot = /sidney-OrionContent/test - var realfileRootPath = sharedUtil.getfileRoot(workspaceId); // = si/sidney/OrionContent - filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); // "/Users/xinyijiang/IBM/openSourceWorkspace/orion.client/modules/orionode/.workspace/si/sidney/OrionContent/test" - }else{ - filePath = path.join(workspaceRoot, req.params["0"]); - } + var fileRoot =path.join("/", workspaceId, req.params["0"]); // fileRoot = /sidney-OrionContent/test + var realfileRootPath = sharedUtil.getfileRoot(workspaceId); // = si/sidney/OrionContent + filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); // "/Users/xinyijiang/IBM/openSourceWorkspace/orion.client/modules/orionode/.workspace/si/sidney/OrionContent/test" var readIfExists = req.headers ? Boolean(req.headers['read-if-exists']).valueOf() : false; fileUtil.withStatsAndETag(filePath, function(err, stats, etag) { if (err && err.code === 'ENOENT') { @@ -151,7 +147,7 @@ module.exports.router = function(options) { }); }else{ return sharedUtil.getSharedProjects(req, res, function(projects) { - var tree = sharedUtil.treeJSON(req.params.WorkspaceId, "/" + workspaceId, 0, true, 0, false); + var tree = sharedUtil.treeJSON(req.params.workspaceId, "/" + workspaceId, 0, true, 0, false); var children = tree.Children = []; function add(projects) { projects.forEach(function(project) { @@ -315,10 +311,6 @@ module.exports.router = function(options) { writeError(404, res, 'Session not found: ' + hubid); return; } - // remove project name from path - filepath = filepath.substring(0, filepath.lastIndexOf(path.sep)); - filepath = path.join(filepath, relativeFilePath); - req.params['0'] = filepath; getTree(req, res); }); } @@ -329,7 +321,7 @@ module.exports.router = function(options) { writeError(403, res, 'Forbidden'); return; } - var relativeFilePath = req.params['0']; + // var relativeFilePath = req.params['0']; var hubid = req.params['hubId']; return sharedProjects.getProjectPathFromHubID(hubid) @@ -339,9 +331,9 @@ module.exports.router = function(options) { return; } //remove project name from path - filepath = filepath.substring(0, filepath.lastIndexOf(path.sep)); - filepath = path.join(filepath, relativeFilePath); - req.params['0'] = filepath; + // filepath = filepath.substring(0, filepath.lastIndexOf(path.sep)); + // filepath = path.join(filepath, relativeFilePath); + // req.params['0'] = filepath; putFile(req, res); }); } From 6b09d599060113cd420350394e6dda14fb4a4935 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 12:13:09 -0400 Subject: [PATCH 14/37] cleaned more stuff, added file copied socket handler Signed-off-by: Sidney --- .../web/orion/collab/collabClient.js | 28 ++++++++--------- .../web/orion/collab/collabFileImpl.js | 4 +-- modules/orionode/endpoint.json | 8 ----- modules/orionode/index.js | 4 ++- modules/orionode/lib/fileUtil.js | 4 +-- modules/orionode/lib/orion_static.js | 1 + modules/orionode/lib/shared/tree.js | 30 +++++++++---------- modules/orionode/lib/sharedWorkspace.js | 26 +++++++--------- modules/orionode/orion.conf | 4 +-- 9 files changed, 50 insertions(+), 59 deletions(-) delete mode 100644 modules/orionode/endpoint.json diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js index 0313622def..d053d82ad8 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabClient.js @@ -196,7 +196,7 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot var name = (peer && peer.name) ? peer.name : 'Unknown'; var color = (peer && peer.color) ? peer.color : '#000000'; if (url) { - url = this.maybeTransformLocation(url); + url = this.getFileSystemPrefix() + url; } this.collabFileAnnotations[clientId] = new CollabFileAnnotation(name, color, url, this.projectRelativeLocation(url), editing); this._requestFileAnnotationUpdate(); @@ -532,32 +532,32 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot switch (operation) { case 'created': - var parentLocation = this.maybeTransformLocation(data.parent); + var parentLocation = this.transformLocation(data.parent); var result = data.result; if (result) { result.Parents = []; //is parents even needed for this operation? - result.Location = this.maybeTransformLocation(result.Location); + result.Location = this.transformLocation(result.Location); } evt.created = [{'parent': parentLocation, 'result': result, 'eventData': evtData}]; break; case 'deleted': - var deleteLocation = this.maybeTransformLocation(data.deleteLocation); + var deleteLocation = this.transformLocation(data.deleteLocation); evt.deleted = [{'deleteLocation': deleteLocation, 'eventData': evtData}]; break; case 'moved': - var sourceLocation = this.maybeTransformLocation(data.source); - var targetLocation = this.maybeTransformLocation(data.target); + var sourceLocation = this.transformLocation(data.source); + var targetLocation = this.transformLocation(data.target); var result = data.result; result.Parents = []; //is parents even needed for this operation? - result.Location = this.maybeTransformLocation(result.Location); + result.Location = this.transformLocation(result.Location); evt.moved = [{'source': sourceLocation, 'target': targetLocation, 'result': result, 'eventData': evtData}]; break; case 'copied': - var sourceLocation = this.maybeTransformLocation(data.source); - var targetLocation = this.maybeTransformLocation(data.target); + var sourceLocation = this.transformLocation(data.source); + var targetLocation = this.transformLocation(data.target); var result = data.result; result.Parents = []; //is parents even needed for this operation? - result.Location = this.maybeTransformLocation(result.Location); + result.Location = this.transformLocation(result.Location); evt.copied = [{'source': sourceLocation, 'target': targetLocation, 'result': result, 'eventData': evtData}]; break; } @@ -566,15 +566,15 @@ define(['orion/collab/ot', 'orion/collab/collabFileAnnotation', 'orion/collab/ot }, /** - * For example we potentially need to convert a '/file/web/potato.js' to '/sharedWorkspace/tree/file/web/potato.js' + * For example we need to convert a '/file/web-orionContent/potato.js' to '/sharedWorkspace/tree/file/web-orionContent/potato.js' * and vice-versa, depending on our file system and the sender's filesystem. */ - maybeTransformLocation: function(Location) { //Location = "/file/orders-api-uselessTesting_X/app.js" + transformLocation: function(location) { //Location = "/file/orders-api-uselessTesting_X/app.js" + var filePath = location.replace(/^.*\/file/, ""); var loc = this.getFileSystemPrefix(); - return loc + Location; + return loc + filePath; }, - /* * Generate the location string for the tooltip for each collabrator. */ diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js index 8722e70e97..95a49740e2 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabFileImpl.js @@ -190,11 +190,11 @@ define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], functio "X-Create-Options": "no-overwrite," + (isMove ? "move" : "copy"), "Content-Type": "application/json;charset=UTF-8" }, + timeout : COLLAB_TIMEOUT, data: JSON.stringify({ "Location": sourceLocation, "Name": _name - }), - timeout: 15000 + }) }).then(function(result) { return result.response ? JSON.parse(result.response) : null; }); diff --git a/modules/orionode/endpoint.json b/modules/orionode/endpoint.json deleted file mode 100644 index d8ffc8e1a1..0000000000 --- a/modules/orionode/endpoint.json +++ /dev/null @@ -1,8 +0,0 @@ -[{ - "endpoint": "/sharedWorkspace", - "module": "./lib/sharedWorkspace", - "extraOptions": { - "root": "/sharedWorkspace/tree/file", - "fileRoot": "/file" - } -}] \ No newline at end of file diff --git a/modules/orionode/index.js b/modules/orionode/index.js index ea3894d6db..784a19c494 100755 --- a/modules/orionode/index.js +++ b/modules/orionode/index.js @@ -69,7 +69,7 @@ function startServer(options) { additionalEndpoints.forEach(function(additionalEndpoint){ if(additionalEndpoint.endpoint){ additionalEndpoint.authenticated ? app.use(additionalEndpoint.endpoint, checkAuthenticated, require(additionalEndpoint.module).router(options)) - : app.use(additionalEndpoint.endpoint, require(additionalEndpoint.module).router(options, additionalEndpoint.extraOptions)); + : app.use(additionalEndpoint.endpoint, require(additionalEndpoint.module).router(options)); }else{ var extraModule = require(additionalEndpoint.module); var middleware = extraModule.router ? extraModule.router(options) : extraModule(options); @@ -77,6 +77,8 @@ function startServer(options) { } }); } + + app.use('/sharedWorkspace', require('./lib/sharedWorkspace').router({sharedWorkspaceFileRoot: contextPath + '/sharedWorkspace/tree/file', fileRoot: contextPath + '/file', options: options })); app.use(require('./lib/user').router(options)); app.use('/site', checkAuthenticated, require('./lib/sites')(options)); app.use('/task', checkAuthenticated, require('./lib/tasks').router({ taskRoot: contextPath + '/task', options: options})); diff --git a/modules/orionode/lib/fileUtil.js b/modules/orionode/lib/fileUtil.js index b42c3ce24d..e4f92d1c89 100644 --- a/modules/orionode/lib/fileUtil.js +++ b/modules/orionode/lib/fileUtil.js @@ -380,8 +380,8 @@ function fileJSON(fileRoot, workspaceRoot, file, stats, depth, metadataMixins) { /** * Helper for fulfilling a file POST request (for example, copy, move, or create). - * @param {String} fileRoot The route of the /file handler (not including context path) * @param {String} workspaceRoot The route of the /workspace handler (not including context path) + * @param {String} fileRoot The route of the /file handler (not including context path) * @param {Object} req * @parma {Object} res * @param {String} wwwpath @@ -428,7 +428,7 @@ exports.handleFilePOST = function(workspaceRoot, fileRoot, req, res, destFile, m if (!sourceUrl) { return api.writeError(400, res, 'Missing Location property in request body'); } - var sourceFile = getFile(req, sourceUrl.replace(/^\/file/, "")); + var sourceFile = getFile(req, sourceUrl.replace(/^.*\/file/, "")); return fs.statAsync(sourceFile.path) .then(function(/*stats*/) { return isCopy ? copy(sourceFile.path, destFile.path) : fs.renameAsync(sourceFile.path, destFile.path); diff --git a/modules/orionode/lib/orion_static.js b/modules/orionode/lib/orion_static.js index 2a4e6b05fb..0ccba60562 100644 --- a/modules/orionode/lib/orion_static.js +++ b/modules/orionode/lib/orion_static.js @@ -55,6 +55,7 @@ exports = module.exports = function(options) { './bundles/org.eclipse.orion.client.webtools/web', './bundles/org.eclipse.orion.client.users/web', './bundles/org.eclipse.orion.client.cf/web', + './bundles/org.eclipse.orion.client.collab/web' ]; var fullStaticAssets = options.prependStaticAssets.concat(originalStaticAssets).concat(options.appendStaticAssets); fullStaticAssets = fullStaticAssets.forEach(function(bundlePath) { diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index b50bde618e..373acd6eb0 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -14,21 +14,21 @@ var path = require('path'); var express = require('express'); var sharedUtil = require('./sharedUtil'); var fileUtil = require('../fileUtil'); -var ofile = require('../file'); var fs = require('fs'); var sharedProjects = require('./db/sharedProjects'); +var xfer = require('../xfer'); +var bodyParser = require('body-parser'); module.exports = {}; module.exports.router = function(options) { - var workspaceRoot = options.options.workspaceDir; - var sharedRoot = options.root; - if (!workspaceRoot) { throw new Error('options.options.workspaceDir required'); } - var xfer = require('../xfer'); - var contextPath = options.options.configParams["orion.context.path"] || ""; + var workspaceDir = options.options.workspaceDir; + if (!workspaceDir) { throw new Error('options.options.workspaceDir required'); } + var sharedWorkspaceFileRoot = options.sharedWorkspaceFileRoot; var SEPARATOR = "-"; return express.Router() + .use(bodyParser.json()) .get('/file/:workspaceId*', getTree) .put('/file*', ensureAccess, putFile) .post('/file*', ensureAccess, postFile) @@ -46,7 +46,7 @@ module.exports.router = function(options) { function getSharedWorkspace(req, res) { return sharedUtil.getSharedProjects(req, res, function(projects) { var workspaces = []; - var sharedWorkspaceRoot = "/sharedWorkspace" + "/tree" + options.fileRoot; + projects.forEach(function(project){ var projectSegs = project.Location.split("/"); var projectBelongingWorkspaceId = projectSegs[2] + SEPARATOR + projectSegs[3]; @@ -55,7 +55,7 @@ module.exports.router = function(options) { })){ workspaces.push({ Id: projectBelongingWorkspaceId, - Location: api.join(sharedWorkspaceRoot, projectBelongingWorkspaceId), + Location: api.join(sharedWorkspaceFileRoot, projectBelongingWorkspaceId), Name: projectSegs[3] }); } @@ -94,7 +94,7 @@ module.exports.router = function(options) { var tree, filePath; var fileRoot =path.join("/", workspaceId, req.params["0"]); // fileRoot = /sidney-OrionContent/test var realfileRootPath = sharedUtil.getfileRoot(workspaceId); // = si/sidney/OrionContent - filePath = path.join(workspaceRoot,realfileRootPath, req.params["0"]); // "/Users/xinyijiang/IBM/openSourceWorkspace/orion.client/modules/orionode/.workspace/si/sidney/OrionContent/test" + filePath = path.join(workspaceDir,realfileRootPath, req.params["0"]); // "/Users/xinyijiang/IBM/openSourceWorkspace/orion.client/modules/orionode/.workspace/si/sidney/OrionContent/test" var readIfExists = req.headers ? Boolean(req.headers['read-if-exists']).valueOf() : false; fileUtil.withStatsAndETag(filePath, function(err, stats, etag) { if (err && err.code === 'ENOENT') { @@ -221,7 +221,7 @@ module.exports.router = function(options) { var rest = req.params["0"].substring(1); var diffPatch = req.headers['x-http-method-override']; if (diffPatch === "PATCH") { - handleDiff(req, res, rest, req.body); + // This shouldn't happen in collaboration return; } var name = fileUtil.decodeSlug(req.headers.slug) || req.body && req.body.Name; @@ -229,10 +229,10 @@ module.exports.router = function(options) { writeError(400, res, new Error('Missing Slug header or Name property')); return; } - - req.user.workspaceDir = workspaceRoot; - var filepath = path.join(workspaceRoot, rest, name); - fileUtil.handleFilePOST(null, contextPath + sharedRoot, req, res, filepath); + + req.user.workspaceDir = workspaceDir; + var file = fileUtil.getFile(req, api.join(rest, name)); + fileUtil.handleFilePOST("/workspace", sharedWorkspaceFileRoot, req, res, file); } /** @@ -266,7 +266,7 @@ module.exports.router = function(options) { function writeFileMetadata(fileRoot, req, res, filepath, stats, etag) { var result; - return fileJSON(fileRoot, workspaceRoot, filepath, stats) + return fileJSON(fileRoot, workspaceDir, filepath, stats) .then(function(originalJson){ result = originalJson; if (etag) { diff --git a/modules/orionode/lib/sharedWorkspace.js b/modules/orionode/lib/sharedWorkspace.js index 29da709da4..b2ea5a1b53 100644 --- a/modules/orionode/lib/sharedWorkspace.js +++ b/modules/orionode/lib/sharedWorkspace.js @@ -16,7 +16,9 @@ var sharedUtil = require('./shared/sharedUtil'); var SharedFileDecorator = require('./shared/sharedDecorator').SharedFileDecorator; var jwt = require('jsonwebtoken'); -module.exports.router = function(options, extraOptions) { +module.exports.router = function(options) { + if (!options.fileRoot) { throw new Error('options.fileRoot is required'); } + if (!options.sharedWorkspaceFileRoot) { throw new Error('options.sharedWorkspaceFileRoot is required'); } /** * This method ensures that the websocket trying to retrieve and save content is authenticated. @@ -24,7 +26,7 @@ module.exports.router = function(options, extraOptions) { */ function checkCollabAuthenticated(req, res, next) { if (req.user) { - req.user.workspaceDir = options.workspaceDir + (req.user.workspace ? "/" + req.user.workspace : ""); + req.user.workspaceDir = options.options.workspaceDir + (req.user.workspace ? "/" + req.user.workspace : ""); next(); } else if (req.headers['authorization'] && checkCollabServerToken(req.headers['authorization'])){ next(); @@ -42,25 +44,19 @@ module.exports.router = function(options, extraOptions) { return false; } try { - var decoded = jwt.verify(authorization.substr(7), options.configParams["orion.jwt.secret"]); + var decoded = jwt.verify(authorization.substr(7), options.options.configParams["orion.jwt.secret"]); return true; } catch (ex) { return false; } } - - extraOptions.options = options; - var contextPath = options && options.configParams["orion.context.path"] || ""; - var fileRoot = extraOptions.fileRoot; - if (!fileRoot) { throw new Error('extraOptions.root path required'); } - extraOptions.fileRoot = contextPath + fileRoot; var router = express.Router(); - router.use("/tree", tree.router(extraOptions)); - router.use("/project", require('./shared/db/sharedProjects')(extraOptions)); - router.use("/user", require('./shared/db/userProjects')(extraOptions)); - fileUtil.addDecorator(new SharedFileDecorator(options)); - sharedUtil(extraOptions); + router.use("/tree", tree.router(options)); + router.use("/project", require('./shared/db/sharedProjects')(options)); + router.use("/user", require('./shared/db/userProjects')(options)); + fileUtil.addDecorator(new SharedFileDecorator(options.options)); + sharedUtil(options); return [checkCollabAuthenticated, router]; -} +}; \ No newline at end of file diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index bfafd2907b..3436273017 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -47,13 +47,13 @@ mail.from= prepend.static.assets= #serve these static assets after the original default ones(will not overwrite original ones, but might be overwritten), relative to orion_static.js -append.static.assets=./bundles/org.eclipse.orion.client.collab/web +append.static.assets= #defines where cf get bearer token from cf.bearer.token.store= #specify the path of the json file which defines additional endpoints -additional.endpoint=./endpoint.json +additional.endpoint= #serve additional modules additional.modules.path= From 36b62e53ae665283cf5ff0a088291e8d7f998379 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 12:14:09 -0400 Subject: [PATCH 15/37] forgot this file Signed-off-by: Sidney --- modules/orionode.collab.hub/session.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/orionode.collab.hub/session.js b/modules/orionode.collab.hub/session.js index 86300fbd3b..9dfc250a8e 100644 --- a/modules/orionode.collab.hub/session.js +++ b/modules/orionode.collab.hub/session.js @@ -208,6 +208,9 @@ class Session { } }); this.notifyAll(c, outMsg); + } else if(msg.operation === 'copied'){ + outMsg.operation = 'copied'; + this.notifyAll(c, outMsg); } else { c.send(JSON.stringify({ type: 'error', From 8c802a3ac9b1c4d0e6696a5cda98f85e4d824b3a Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 14:39:31 -0400 Subject: [PATCH 16/37] call the fixes end for now Signed-off-by: Sidney --- modules/orionode/lib/shared/sharedUtil.js | 18 ++++++++++++------ modules/orionode/lib/shared/tree.js | 10 ++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/modules/orionode/lib/shared/sharedUtil.js b/modules/orionode/lib/shared/sharedUtil.js index 4317895172..ba71f9d720 100644 --- a/modules/orionode/lib/shared/sharedUtil.js +++ b/modules/orionode/lib/shared/sharedUtil.js @@ -19,8 +19,8 @@ var userProjects = require('./db/userProjects'); module.exports = function(options) { var SEPARATOR = "-"; - var workspaceRoot = options.options.workspaceDir; - if (!workspaceRoot) { throw new Error('options.options.workspaceDir path required'); } + var workspaceDir = options.options.workspaceDir; + if (!workspaceDir) { throw new Error('options.options.workspaceDir path required'); } var contextPath = options.options.configParams['orion.context.path']; @@ -33,7 +33,8 @@ module.exports = function(options) { module.exports.decodeUserIdFromWorkspaceId = decodeUserIdFromWorkspaceId; module.exports.decodeWorkspaceNameFromWorkspaceId = decodeWorkspaceNameFromWorkspaceId; module.exports.getProjectRoot = getProjectRoot; - module.exports.getProjectLocationFromWorkspaceId = getProjectLocationFromWorkspaceId; + module.exports.getRealLocation = getRealLocation; + module.exports.getWorkspaceIdFromprojectLocation = getWorkspaceIdFromprojectLocation; function projectExists(fullpath) { return fs.existsSync(fullpath); @@ -165,9 +166,9 @@ module.exports = function(options) { * return: "\mo\mourad\OrionContent\web" which is the unique project path format that can be found in the database. */ function getProjectRoot(filepath) { - var index = filepath.indexOf(workspaceRoot); + var index = filepath.indexOf(workspaceDir); if (index === -1) return undefined; - index += workspaceRoot.length; + index += workspaceDir.length; filepath = filepath.substring(index); return filepath.split(path.sep).slice(0,5).join(path.sep); } @@ -175,10 +176,15 @@ module.exports = function(options) { /** * /user1-orionContent/test/testfile -> /us/user1/orionContent/test */ - function getProjectLocationFromWorkspaceId(filepath) { + function getRealLocation(filepath) { var segs = filepath.split(path.sep); var fileRoot = getfileRoot(segs[1]); var realPath = path.join("/", fileRoot, segs[2]); return realPath; } + + function getWorkspaceIdFromprojectLocation(projectLocation){ + var projectSegs = projectLocation.split("/"); + return projectSegs[2] + SEPARATOR + projectSegs[3]; + } } \ No newline at end of file diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index 373acd6eb0..620be56c33 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -25,7 +25,6 @@ module.exports.router = function(options) { var workspaceDir = options.options.workspaceDir; if (!workspaceDir) { throw new Error('options.options.workspaceDir required'); } var sharedWorkspaceFileRoot = options.sharedWorkspaceFileRoot; - var SEPARATOR = "-"; return express.Router() .use(bodyParser.json()) @@ -46,10 +45,9 @@ module.exports.router = function(options) { function getSharedWorkspace(req, res) { return sharedUtil.getSharedProjects(req, res, function(projects) { var workspaces = []; - projects.forEach(function(project){ var projectSegs = project.Location.split("/"); - var projectBelongingWorkspaceId = projectSegs[2] + SEPARATOR + projectSegs[3]; + var projectBelongingWorkspaceId = sharedUtil.getWorkspaceIdFromprojectLocation(project.Location); if(!workspaces.some(function(workspace){ return workspace.Id === projectBelongingWorkspaceId; })){ @@ -70,12 +68,12 @@ module.exports.router = function(options) { } function ensureAccess(req, res, next) { - var project = sharedUtil.getProjectLocationFromWorkspaceId(req.params["0"]); + var project = sharedUtil.getRealLocation(req.params["0"]); var username = req.user.username; sharedProjects.getUsersInProject(project) .then(function(users) { - if (!project || !users || !users.some(function(user) {return user == username})) { + if (!project || !users || !users.some(function(user) {return user === username;})) { res.writeHead(401, "Not authenticated"); res.end(); } else { @@ -152,7 +150,7 @@ module.exports.router = function(options) { function add(projects) { projects.forEach(function(project) { var projectSegs = project.Location.split("/"); - var projectBelongingWorkspaceId = projectSegs[2] + SEPARATOR + projectSegs[3]; + var projectBelongingWorkspaceId = sharedUtil.getWorkspaceIdFromprojectLocation(project.Location); if(projectBelongingWorkspaceId === workspaceId){ children.push(sharedUtil.treeJSON(project.Name, path.join("/", projectBelongingWorkspaceId, projectSegs[4]), 0, true, 0, false)); if (project.Children) add(project.Children); From 766732cf23e91880ff5c10167618196bfa1006c4 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 15:42:06 -0400 Subject: [PATCH 17/37] move doc from client code to server Signed-off-by: Sidney --- .../web/orion/collab/shareProjectClient.js | 2 +- .../orionode/lib/shared/doc}/README.md | 0 .../orionode/lib/shared/doc}/img/Auth_diagram.png | Bin .../orionode/lib/shared/doc}/img/hub_server.jpg | Bin .../lib/shared/doc}/img/shared_workspace_server.jpg | Bin 5 files changed, 1 insertion(+), 1 deletion(-) rename {bundles/org.eclipse.orion.client.collab/web => modules/orionode/lib/shared/doc}/README.md (100%) rename {bundles/org.eclipse.orion.client.collab/web => modules/orionode/lib/shared/doc}/img/Auth_diagram.png (100%) rename {bundles/org.eclipse.orion.client.collab/web => modules/orionode/lib/shared/doc}/img/hub_server.jpg (100%) rename {bundles/org.eclipse.orion.client.collab/web => modules/orionode/lib/shared/doc}/img/shared_workspace_server.jpg (100%) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js index 194fddde71..b9228b426b 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/shareProjectClient.js @@ -11,7 +11,7 @@ /*eslint-env browser, amd*/ /*global URL*/ -define(["orion/xhr", "orion/Deferred", "orion/URL-shim", "orion/form"], function(xhr, Deferred, _, form) { +define(["orion/xhr", "orion/URL-shim"], function(xhr) { var contextPath = location.pathname.substr(0, location.pathname.lastIndexOf('/edit/edit.html')); return { shareProjectUrl: contextPath + "/sharedWorkspace/project/", diff --git a/bundles/org.eclipse.orion.client.collab/web/README.md b/modules/orionode/lib/shared/doc/README.md similarity index 100% rename from bundles/org.eclipse.orion.client.collab/web/README.md rename to modules/orionode/lib/shared/doc/README.md diff --git a/bundles/org.eclipse.orion.client.collab/web/img/Auth_diagram.png b/modules/orionode/lib/shared/doc/img/Auth_diagram.png similarity index 100% rename from bundles/org.eclipse.orion.client.collab/web/img/Auth_diagram.png rename to modules/orionode/lib/shared/doc/img/Auth_diagram.png diff --git a/bundles/org.eclipse.orion.client.collab/web/img/hub_server.jpg b/modules/orionode/lib/shared/doc/img/hub_server.jpg similarity index 100% rename from bundles/org.eclipse.orion.client.collab/web/img/hub_server.jpg rename to modules/orionode/lib/shared/doc/img/hub_server.jpg diff --git a/bundles/org.eclipse.orion.client.collab/web/img/shared_workspace_server.jpg b/modules/orionode/lib/shared/doc/img/shared_workspace_server.jpg similarity index 100% rename from bundles/org.eclipse.orion.client.collab/web/img/shared_workspace_server.jpg rename to modules/orionode/lib/shared/doc/img/shared_workspace_server.jpg From 4128b781469dc1721f8ade3d205ff4bb5a76cc86 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 16:00:22 -0400 Subject: [PATCH 18/37] ready to merge Signed-off-by: Sidney --- modules/orionode/index.js | 5 +++-- modules/orionode/lib/shared/doc/HowToSetup.md | 6 ++++++ modules/orionode/orion.conf | 6 +++++- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 modules/orionode/lib/shared/doc/HowToSetup.md diff --git a/modules/orionode/index.js b/modules/orionode/index.js index 784a19c494..df250d7895 100755 --- a/modules/orionode/index.js +++ b/modules/orionode/index.js @@ -77,8 +77,9 @@ function startServer(options) { } }); } - - app.use('/sharedWorkspace', require('./lib/sharedWorkspace').router({sharedWorkspaceFileRoot: contextPath + '/sharedWorkspace/tree/file', fileRoot: contextPath + '/file', options: options })); + if(options.configParams["collabMode"]){ + app.use('/sharedWorkspace', require('./lib/sharedWorkspace').router({sharedWorkspaceFileRoot: contextPath + '/sharedWorkspace/tree/file', fileRoot: contextPath + '/file', options: options })); + } app.use(require('./lib/user').router(options)); app.use('/site', checkAuthenticated, require('./lib/sites')(options)); app.use('/task', checkAuthenticated, require('./lib/tasks').router({ taskRoot: contextPath + '/task', options: options})); diff --git a/modules/orionode/lib/shared/doc/HowToSetup.md b/modules/orionode/lib/shared/doc/HowToSetup.md new file mode 100644 index 0000000000..2c40d62744 --- /dev/null +++ b/modules/orionode/lib/shared/doc/HowToSetup.md @@ -0,0 +1,6 @@ +- 1: in bundles/org.eclipse.orion.client.ui/web/defaults.pref, add another plugin: collab/plugins/collabPlugin.html": true under "/plugins" +- 2: in bundles/org.eclipse.orion.client.ui/web/defaults.pref, add another default value "/collab": { "hubUrl": "ws://localhost:8082/" }, the value "localhost:8082" depends on your orion.collab server's url. +- 3: in modules/orionode/orion.conf, change "orion.single.user" to false, and add a random string to "orion.jwt.secret", and change "collabMode" to true; +- 4: in modules/orionode.collab.hub/config.js use the same random string to "jwt_secret", and specify the main server's url to "orion" + + diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 3436273017..1631558fc4 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -17,7 +17,7 @@ orion.oauth.github.client= orion.oauth.github.secret= orion.oauth.google.client= orion.oauth.google.secret= -orion.jwt.secret=orion collab + orion.single.user=false orion.buildId= @@ -60,3 +60,7 @@ additional.modules.path= #shutdown timeout, wait for a period of time before force process exit shutdown.timeout=10000 + +#The attributes for collaboration +collabMode=true +orion.jwt.secret=orion collab \ No newline at end of file From 2769a8bce544b3de371d14b5e3b8a57d23ea3c77 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 16:02:14 -0400 Subject: [PATCH 19/37] hide collab Signed-off-by: Sidney --- bundles/org.eclipse.orion.client.ui/web/defaults.pref | 6 ++---- modules/orionode/orion.conf | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index c7b41e958b..9a22106ec0 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -10,8 +10,6 @@ "edit/content/imageViewerPlugin.html":true, "edit/content/jsonEditorPlugin.html":true, "cfui/plugins/cFPlugin.html":true, - "cfui/plugins/cFDeployPlugin.html":true, - "collab/plugins/collabPlugin.html": true - }, - "/collab": { "hubUrl": "ws://localhost:8082/" } + "cfui/plugins/cFDeployPlugin.html":true + } } \ No newline at end of file diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 1631558fc4..56c80896f6 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -62,5 +62,5 @@ additional.modules.path= shutdown.timeout=10000 #The attributes for collaboration -collabMode=true -orion.jwt.secret=orion collab \ No newline at end of file +collabMode=false +orion.jwt.secret= \ No newline at end of file From f52b08a236a58d44e19cc011ad3a98901c62da15 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 16 Jun 2017 16:04:15 -0400 Subject: [PATCH 20/37] tweak Signed-off-by: Sidney --- modules/orionode/lib/shared/doc/HowToSetup.md | 3 ++- modules/orionode/orion.conf | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/orionode/lib/shared/doc/HowToSetup.md b/modules/orionode/lib/shared/doc/HowToSetup.md index 2c40d62744..b6e0020820 100644 --- a/modules/orionode/lib/shared/doc/HowToSetup.md +++ b/modules/orionode/lib/shared/doc/HowToSetup.md @@ -2,5 +2,6 @@ - 2: in bundles/org.eclipse.orion.client.ui/web/defaults.pref, add another default value "/collab": { "hubUrl": "ws://localhost:8082/" }, the value "localhost:8082" depends on your orion.collab server's url. - 3: in modules/orionode/orion.conf, change "orion.single.user" to false, and add a random string to "orion.jwt.secret", and change "collabMode" to true; - 4: in modules/orionode.collab.hub/config.js use the same random string to "jwt_secret", and specify the main server's url to "orion" - +- 5: run mongodb, run orion node server, run orion.collab server +- 6: have fun. diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 56c80896f6..5190c57df6 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -19,7 +19,7 @@ orion.oauth.google.client= orion.oauth.google.secret= -orion.single.user=false +orion.single.user=true orion.buildId= orion.autoUpdater.url= From 48c0b43d10afd4317dcc572f664f5b50c3bf8691 Mon Sep 17 00:00:00 2001 From: Sidney Date: Mon, 19 Jun 2017 10:49:57 -0400 Subject: [PATCH 21/37] fixed simpleMocha failure Signed-off-by: Sidney --- modules/orionode/lib/shared/tree.js | 3 +-- modules/orionode/lib/xfer.js | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/orionode/lib/shared/tree.js b/modules/orionode/lib/shared/tree.js index 620be56c33..5a27b6988f 100644 --- a/modules/orionode/lib/shared/tree.js +++ b/modules/orionode/lib/shared/tree.js @@ -366,8 +366,7 @@ module.exports.router = function(options) { return writeError(400, res, "Export is not a zip"); } - var filePath = file.path.replace(/.zip$/, ""); - xfer.getXferFrom(req, res, filePath); + xfer.getXferFrom(req, res, file); } /** diff --git a/modules/orionode/lib/xfer.js b/modules/orionode/lib/xfer.js index eff963bc88..bbfc39ef78 100644 --- a/modules/orionode/lib/xfer.js +++ b/modules/orionode/lib/xfer.js @@ -250,11 +250,11 @@ function getXfer(req, res) { return writeError(400, res, "Export is not a zip"); } - var filePath = file.path.replace(/.zip$/, ""); - getXferFrom(req, res, filePath); + getXferFrom(req, res, file); } -function getXferFrom(req, res, filePath) { +function getXferFrom(req, res, file) { + var filePath = file.path.replace(/.zip$/, ""); var zip = archiver('zip'); zip.pipe(res); write(zip, filePath, filePath) From 608fed44b262c3c9df7b5f9609ca10651e0dfbd9 Mon Sep 17 00:00:00 2001 From: Sidney Date: Mon, 19 Jun 2017 11:22:10 -0400 Subject: [PATCH 22/37] fixed undefied removeEventListener Signed-off-by: Sidney --- .../web/orion/explorers/explorer-table.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js index 9c3076bf1b..1a4c093591 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js @@ -61,7 +61,7 @@ define([ FileModel.prototype = new mExplorer.ExplorerModel(); objects.mixin(FileModel.prototype, /** @lends orion.explorer.FileModel.prototype */ { destroy: function () { - removeEventListener(this._annotationChangedHandler, this._annotationChangedHandler); + this.fileClient.removeEventListener("AnnotationChanged", this._annotationChangedHandler); mExplorer.ExplorerModel.prototype.destroy.call(this); }, From e808006e76dcb87980d429b2d1f42307e8e4f11b Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 20 Jun 2017 11:13:31 -0400 Subject: [PATCH 23/37] remove unneeded lines Signed-off-by: Sidney --- bundles/org.eclipse.orion.client.ui/web/defaults.pref | 2 +- .../web/orion/explorers/explorer-table.js | 1 - .../org.eclipse.orion.client.ui/web/orion/fileCommands.js | 1 - .../org.eclipse.orion.client.ui/web/orion/webui/treetable.js | 1 - modules/orionode/index.js | 5 +++-- 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index 9a22106ec0..122d31be54 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -12,4 +12,4 @@ "cfui/plugins/cFPlugin.html":true, "cfui/plugins/cFDeployPlugin.html":true } -} \ No newline at end of file +} diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js index 1a4c093591..d010ea9ed8 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/explorers/explorer-table.js @@ -179,7 +179,6 @@ define([ } } } - console.assert(Array.isArray(evt.annotations)); evt.annotations.forEach(function(annotation) { model._annotations.push(annotation); }); diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js b/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js index 0aaaf360bb..5bc02fcdce 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/fileCommands.js @@ -1264,7 +1264,6 @@ define(['i18n!orion/navigate/nls/messages', 'orion/webui/littlelib', 'orion/i18n } }); commandService.addCommand(pasteFromBufferCommand); - return new Deferred().resolve(); }; diff --git a/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js b/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js index 84f1762133..b0a5f77288 100644 --- a/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js +++ b/bundles/org.eclipse.orion.client.ui/web/orion/webui/treetable.js @@ -187,7 +187,6 @@ define(['i18n!orion/nls/messages', 'orion/webui/littlelib', 'orion/Deferred'], f // TODO: should I move this part to renderer? If so, how? var tree = this; var annotations = tree._treeModel.getAnnotations(); - console.assert(Array.isArray(annotations)); // Remove existing annotations var existingAnnotations = this._parent.getElementsByClassName('treeAnnotationOn'); var annotationsToRemove = []; diff --git a/modules/orionode/index.js b/modules/orionode/index.js index df250d7895..7d4553a94c 100755 --- a/modules/orionode/index.js +++ b/modules/orionode/index.js @@ -68,8 +68,9 @@ function startServer(options) { var additionalEndpoints = require(options.configParams["additional.endpoint"]); additionalEndpoints.forEach(function(additionalEndpoint){ if(additionalEndpoint.endpoint){ - additionalEndpoint.authenticated ? app.use(additionalEndpoint.endpoint, checkAuthenticated, require(additionalEndpoint.module).router(options)) - : app.use(additionalEndpoint.endpoint, require(additionalEndpoint.module).router(options)); + additionalEndpoint.authenticated ? + app.use(additionalEndpoint.endpoint, checkAuthenticated, require(additionalEndpoint.module).router(options)) : + app.use(additionalEndpoint.endpoint, require(additionalEndpoint.module).router(options)); }else{ var extraModule = require(additionalEndpoint.module); var middleware = extraModule.router ? extraModule.router(options) : extraModule(options); From 599917e8e3a18f7918ca67a485dcb86644e1e387 Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 20 Jun 2017 12:35:56 -0400 Subject: [PATCH 24/37] more cleanup before merge Signed-off-by: Sidney --- modules/orionode/index.js | 5 ++++- modules/orionode/lib/fileUtil.js | 2 +- modules/orionode/lib/orion_static.js | 3 +-- modules/orionode/orion.conf | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/orionode/index.js b/modules/orionode/index.js index 7d4553a94c..8d4488a75b 100755 --- a/modules/orionode/index.js +++ b/modules/orionode/index.js @@ -78,7 +78,7 @@ function startServer(options) { } }); } - if(options.configParams["collabMode"]){ + if(options.configParams["orion.collab.enabled"]){ app.use('/sharedWorkspace', require('./lib/sharedWorkspace').router({sharedWorkspaceFileRoot: contextPath + '/sharedWorkspace/tree/file', fileRoot: contextPath + '/file', options: options })); } app.use(require('./lib/user').router(options)); @@ -104,6 +104,9 @@ function startServer(options) { var prependStaticAssets = options.configParams["prepend.static.assets"] && options.configParams["prepend.static.assets"].split(",") || []; var appendStaticAssets = options.configParams["append.static.assets"] && options.configParams["append.static.assets"].split(",") || []; var orionode_static = path.normalize(path.join(LIBS, 'orionode.client/')); + if(options.configParams["orion.collab.enabled"]){ + appendStaticAssets.push('./bundles/org.eclipse.orion.client.collab/web'); + } app.use(require('./lib/orion_static')({ orionClientRoot: ORION_CLIENT, maxAge: options.maxAge, orionode_static: orionode_static, prependStaticAssets: prependStaticAssets, appendStaticAssets: appendStaticAssets})); } diff --git a/modules/orionode/lib/fileUtil.js b/modules/orionode/lib/fileUtil.js index e4f92d1c89..8c481bf582 100644 --- a/modules/orionode/lib/fileUtil.js +++ b/modules/orionode/lib/fileUtil.js @@ -428,7 +428,7 @@ exports.handleFilePOST = function(workspaceRoot, fileRoot, req, res, destFile, m if (!sourceUrl) { return api.writeError(400, res, 'Missing Location property in request body'); } - var sourceFile = getFile(req, sourceUrl.replace(/^.*\/file/, "")); + var sourceFile = getFile(req, sourceUrl.replace(new RegExp("^"+fileRoot), "")); return fs.statAsync(sourceFile.path) .then(function(/*stats*/) { return isCopy ? copy(sourceFile.path, destFile.path) : fs.renameAsync(sourceFile.path, destFile.path); diff --git a/modules/orionode/lib/orion_static.js b/modules/orionode/lib/orion_static.js index 0ccba60562..81c9c4003c 100644 --- a/modules/orionode/lib/orion_static.js +++ b/modules/orionode/lib/orion_static.js @@ -54,8 +54,7 @@ exports = module.exports = function(options) { './bundles/org.eclipse.orion.client.git/web', './bundles/org.eclipse.orion.client.webtools/web', './bundles/org.eclipse.orion.client.users/web', - './bundles/org.eclipse.orion.client.cf/web', - './bundles/org.eclipse.orion.client.collab/web' + './bundles/org.eclipse.orion.client.cf/web' ]; var fullStaticAssets = options.prependStaticAssets.concat(originalStaticAssets).concat(options.appendStaticAssets); fullStaticAssets = fullStaticAssets.forEach(function(bundlePath) { diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 5190c57df6..6d8a983312 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -62,5 +62,5 @@ additional.modules.path= shutdown.timeout=10000 #The attributes for collaboration -collabMode=false +orion.collab.enabled= orion.jwt.secret= \ No newline at end of file From 31964f4b1ca47fc13ce0d8dca2a0ae3b30fa4798 Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 20 Jun 2017 12:36:36 -0400 Subject: [PATCH 25/37] use this version of request Signed-off-by: Sidney --- modules/orionode.collab.hub/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/orionode.collab.hub/package.json b/modules/orionode.collab.hub/package.json index 5e07f25750..bfaa6ea54b 100644 --- a/modules/orionode.collab.hub/package.json +++ b/modules/orionode.collab.hub/package.json @@ -14,7 +14,7 @@ "express": "^4.13.3", "jsonwebtoken": "^7.2.0", "ot": "^0.0.15", - "request": "^2.79.0", + "request": "^2.69.0", "ws": "^1.1.1", "hsl-to-rgb-for-reals": "^1.1.0" }, From fe1c59e80ea1a443b75c56223b8fb40d22821217 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 23 Jun 2017 15:20:42 -0400 Subject: [PATCH 26/37] initial attempt Signed-off-by: Sidney --- .../web/orion/collab/collabSocket.js | 7 ++++--- .../web/defaults.pref | 8 ++++--- modules/orionode.collab.hub/package.json | 2 +- modules/orionode.collab.hub/server.js | 21 ++++++++----------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js index 47d953cc85..122e08f160 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js @@ -12,7 +12,7 @@ /*eslint-env browser, amd */ /* Initializes websocket connection */ -define(['orion/EventTarget'], function(EventTarget) { +define(['orion/EventTarget','socket.io/socket.io'], function(EventTarget, io) { 'use strict;' var DEBUG = false; @@ -27,8 +27,9 @@ define(['orion/EventTarget'], function(EventTarget) { */ function CollabSocket(hubUrl, sessionId) { var self = this; - - this.socket = new WebSocket(hubUrl + sessionId); + + this.socket = io.connect( hubUrl+ sessionId, { path: socketioPath }); +// this.socket = new WebSocket(hubUrl + sessionId); this.socket.onopen = function() { self.dispatchEvent({ diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index 122d31be54..5809001828 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -10,6 +10,8 @@ "edit/content/imageViewerPlugin.html":true, "edit/content/jsonEditorPlugin.html":true, "cfui/plugins/cFPlugin.html":true, - "cfui/plugins/cFDeployPlugin.html":true - } -} + "cfui/plugins/cFDeployPlugin.html":true, + "collab/plugins/collabPlugin.html": true + }, + "/collab": { "hubUrl": "http://localhost:8082/" } +} \ No newline at end of file diff --git a/modules/orionode.collab.hub/package.json b/modules/orionode.collab.hub/package.json index bfaa6ea54b..2d81542bfd 100644 --- a/modules/orionode.collab.hub/package.json +++ b/modules/orionode.collab.hub/package.json @@ -15,7 +15,7 @@ "jsonwebtoken": "^7.2.0", "ot": "^0.0.15", "request": "^2.69.0", - "ws": "^1.1.1", + "socket.io": "~1.4.8", "hsl-to-rgb-for-reals": "^1.1.0" }, "devDependencies": {}, diff --git a/modules/orionode.collab.hub/server.js b/modules/orionode.collab.hub/server.js index c1d061dc81..45547cce07 100644 --- a/modules/orionode.collab.hub/server.js +++ b/modules/orionode.collab.hub/server.js @@ -17,26 +17,23 @@ var http = require('http'); var jwt = require('jsonwebtoken'); var SessionManager = require('./session_manager'); var url = require('url'); -var ws = require('ws'); var JWT_SECRET = config.jwt_secret; var app = express(); var server = http.createServer(app); -var wss = new ws.Server({ - server: server -}); +var io = require('socket.io')(server); var sessions = new SessionManager(); -wss.on('connection', function(ws) { +io.on('connection', function(sock) { // Get session ID - var sessionId = url.parse(ws.upgradeReq.url).pathname.substr(1); + var sessionId = url.parse(sock.upgradeReq.url).pathname.substr(1); /** * Handle the initial message (authentication) * Once this client is authenticated, assign it to a session. */ - ws.on('message', function initMsgHandler(msg) { + sock.on('message', function initMsgHandler(msg) { try { var msgObj = JSON.parse(msg); if (msgObj.type !== 'authenticate') { @@ -50,14 +47,14 @@ wss.on('connection', function(ws) { var user = jwt.verify(msgObj.token, JWT_SECRET); // Give the control to a session - sessions.addConnection(sessionId, ws, msgObj.clientId, user.username).then(function() { - ws.removeListener('message', initMsgHandler); - ws.send(JSON.stringify({ type: 'authenticated' })); + sessions.addConnection(sessionId, sock, msgObj.clientId, user.username).then(function() { +// sock.removeListener('message', initMsgHandler); + sock.send(JSON.stringify({ type: 'authenticated' })); }).catch(function(err) { - ws.send(JSON.stringify({ type: 'error', error: err })); + sock.send(JSON.stringify({ type: 'error', error: err })); }); } catch (ex) { - ws.send(JSON.stringify({ + sock.send(JSON.stringify({ type: 'error', message: ex.message })); From f827c2b8b0ad5964cdd0d06c9ce18a47a31dd101 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 23 Jun 2017 15:20:46 -0400 Subject: [PATCH 27/37] keep going Signed-off-by: Sidney --- .../web/orion/collab/collabSocket.js | 28 +++++++++---------- modules/orionode.collab.hub/server.js | 4 +-- modules/orionode/orion.conf | 6 ++-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js index 122e08f160..acbdf27868 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js @@ -28,39 +28,39 @@ define(['orion/EventTarget','socket.io/socket.io'], function(EventTarget, io) { function CollabSocket(hubUrl, sessionId) { var self = this; - this.socket = io.connect( hubUrl+ sessionId, { path: socketioPath }); + this.socket = io.connect( hubUrl+ "?sessionId=" +sessionId, { path: "/socket.io/" }); // this.socket = new WebSocket(hubUrl + sessionId); - this.socket.onopen = function() { + this.socket.on('connect', function() { self.dispatchEvent({ type: 'ready' }); - }; - - this.socket.onclose = function() { + }); + + this.socket.on('disconnect', function() { self.dispatchEvent({ type: 'close' }); - }; - - this.socket.onerror = function(e) { - self.dispatchEvent({ + }); + + this.socket.on('error', function() { + self.dispatchEvent({ type: 'error', error: e }); console.error(e); - }; + }); - this.socket.onmessage = function(e) { + this.socket.on('message', function(data) { self.dispatchEvent({ type: 'message', - data: e.data + data: data }); if (DEBUG) { - var msgObj = JSON.parse(e.data); + var msgObj = JSON.parse(data); console.log('CollabSocket In: ' + msgObj.type, msgObj); } - }; + }); EventTarget.attach(this); } diff --git a/modules/orionode.collab.hub/server.js b/modules/orionode.collab.hub/server.js index 45547cce07..0edea54b8f 100644 --- a/modules/orionode.collab.hub/server.js +++ b/modules/orionode.collab.hub/server.js @@ -22,12 +22,12 @@ var JWT_SECRET = config.jwt_secret; var app = express(); var server = http.createServer(app); -var io = require('socket.io')(server); +var io = require('socket.io').listen(server); var sessions = new SessionManager(); io.on('connection', function(sock) { // Get session ID - var sessionId = url.parse(sock.upgradeReq.url).pathname.substr(1); + var sessionId = sock.conn.request._query.sessionId; /** * Handle the initial message (authentication) diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 6d8a983312..79fad0ec99 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -19,7 +19,7 @@ orion.oauth.google.client= orion.oauth.google.secret= -orion.single.user=true +orion.single.user=false orion.buildId= orion.autoUpdater.url= @@ -62,5 +62,5 @@ additional.modules.path= shutdown.timeout=10000 #The attributes for collaboration -orion.collab.enabled= -orion.jwt.secret= \ No newline at end of file +orion.collab.enabled=true +orion.jwt.secret=orion collab \ No newline at end of file From f5e725659975e440384d0ad4313717775e1064e4 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 23 Jun 2017 15:20:51 -0400 Subject: [PATCH 28/37] now all seems working Signed-off-by: Sidney --- .../web/orion/collab/collabSocket.js | 3 +- modules/orionode.collab.hub/document.js | 10 +-- modules/orionode.collab.hub/server.js | 2 +- modules/orionode.collab.hub/session.js | 78 +++++++++---------- .../orionode.collab.hub/session_manager.js | 24 +++--- 5 files changed, 58 insertions(+), 59 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js index acbdf27868..ec3efd02c1 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/collabSocket.js @@ -29,7 +29,6 @@ define(['orion/EventTarget','socket.io/socket.io'], function(EventTarget, io) { var self = this; this.socket = io.connect( hubUrl+ "?sessionId=" +sessionId, { path: "/socket.io/" }); -// this.socket = new WebSocket(hubUrl + sessionId); this.socket.on('connect', function() { self.dispatchEvent({ @@ -43,7 +42,7 @@ define(['orion/EventTarget','socket.io/socket.io'], function(EventTarget, io) { }); }); - this.socket.on('error', function() { + this.socket.on('error', function(e) { self.dispatchEvent({ type: 'error', error: e diff --git a/modules/orionode.collab.hub/document.js b/modules/orionode.collab.hub/document.js index 93eff22087..0f0db50c8e 100644 --- a/modules/orionode.collab.hub/document.js +++ b/modules/orionode.collab.hub/document.js @@ -81,7 +81,7 @@ class Document { /** * Add a client to this document * - * @param {WebSocket} connection + * @param {Socket.io} connection * @param {string} clientId * @param {Client} client */ @@ -136,7 +136,7 @@ class Document { /** * Handle incoming message * - * @param {WebSocket} connection + * @param {Socket.io} connection * @param {Object} msg * @param {Client} client */ @@ -179,7 +179,7 @@ class Document { /** * Initialize client's document * - * @param {WebSocket} c + * @param {Socket.io} c */ sendInit(c) { var self = this; @@ -271,7 +271,7 @@ class Document { /** * Send every client's selection * - * @param {WebSocket} connection + * @param {Socket.io} connection */ sendAllSelections(connection) { this.clients.forEach(function(client, clientConnection) { @@ -317,7 +317,7 @@ class Document { /** * Broadcast message * - * @param {WebSocket} connection + * @param {Socket.io} connection * @param {Object} message * @param {boolean} [includeSender=false] */ diff --git a/modules/orionode.collab.hub/server.js b/modules/orionode.collab.hub/server.js index 0edea54b8f..c6c4b234c9 100644 --- a/modules/orionode.collab.hub/server.js +++ b/modules/orionode.collab.hub/server.js @@ -48,7 +48,7 @@ io.on('connection', function(sock) { // Give the control to a session sessions.addConnection(sessionId, sock, msgObj.clientId, user.username).then(function() { -// sock.removeListener('message', initMsgHandler); + sock.removeListener('message', initMsgHandler); sock.send(JSON.stringify({ type: 'authenticated' })); }).catch(function(err) { sock.send(JSON.stringify({ type: 'error', error: err })); diff --git a/modules/orionode.collab.hub/session.js b/modules/orionode.collab.hub/session.js index 9dfc250a8e..782bf9a6c7 100644 --- a/modules/orionode.collab.hub/session.js +++ b/modules/orionode.collab.hub/session.js @@ -38,7 +38,7 @@ class ConnectionStatus { class Session { constructor(sessionId) { - /** @type {Map.} */ + /** @type {Map.} */ this.clients = new Map(); this.connectionStats = new ConnectionStatus(); /** @type {Object.} */ @@ -49,13 +49,13 @@ class Session { /** * Add a connection * - * @param {WebSocket} c + * @param {Socket.io} io * @param {Client} client */ - connectionJoined(c, client) { - this.clients.set(c, client); + connectionJoined(io, client) { + this.clients.set(io, client); this.connectionStats.connections++; - this.notifyAll(c, { + this.notifyAll(io, { type: 'client-joined', clientId: client.clientId, name: client.name, @@ -66,17 +66,17 @@ class Session { /** * Remove a connection * - * @param {WebSocket} c + * @param {Socket.io} io * @param {function} callback - calls when it's done, with a boolean * parameter indicates whether there is no conenctions left. */ - connectionLeft(c, callback) { + connectionLeft(io, callback) { var self = this; - var client = this.clients.get(c); + var client = this.clients.get(io); if (client) { - this.clients.delete(c); + this.clients.delete(io); - this.notifyAll(c, { + this.notifyAll(io, { type: 'client-left', clientId: client.clientId }); @@ -85,7 +85,7 @@ class Session { var doc = client.doc; if (doc && this.docs[doc]) { // check with the document if this is the last user. If so, clear the doc from memory. - this.docs[doc].leaveDocument(c, client.clientId, function(lastPerson) { + this.docs[doc].leaveDocument(io, client.clientId, function(lastPerson) { if (lastPerson) { self.docs[doc].destroy(); delete self.docs[doc]; @@ -101,15 +101,15 @@ class Session { /** * Handles incoming message * - * @param {WebSocket} c + * @param {Socket.io} io * @param {Object} msg */ - onmessage(c, msg) { - var client = this.clients.get(c); + onmessage(io, msg) { + var client = this.clients.get(io); var self = this; if (msg.type === 'ping') { - c.send(JSON.stringify({ + io.send(JSON.stringify({ type: 'pong' })); return; @@ -118,14 +118,14 @@ class Session { // if its a doc specific message, only send it to the clients involved. Otherwise send to all. if (msg.doc) { if (msg.type === 'join-document') { - this.joinDocument(c, msg, client, msg.doc); + this.joinDocument(io, msg, client, msg.doc); client.doc = msg.doc; } else { var doc = this.docs[msg.doc]; if (doc) { - doc.onmessage(c, msg, client); + doc.onmessage(io, msg, client); } else { - c.send(JSON.stringify({ + io.send(JSON.stringify({ type: 'error', error: 'Invalid document ' + msg.doc })); @@ -134,7 +134,7 @@ class Session { } else { if (msg.type === 'leave-document') { if (client.doc && this.docs[client.doc]) { - this.leaveDocument(c, msg, client); + this.leaveDocument(io, msg, client); } } else if (msg.type === 'update-client') { if (msg.name) { @@ -151,12 +151,12 @@ class Session { } var outMsg = client.serialize(); outMsg.type = 'client-updated'; - this.notifyAll(c, outMsg); + this.notifyAll(io, outMsg); } else if (msg.type === 'get-clients') { this.clients.forEach(function(peerClient) { var outMsg = peerClient.serialize(); outMsg.type = 'client-joined'; - c.send(JSON.stringify(outMsg)); + io.send(JSON.stringify(outMsg)); }); } else if (msg.type === 'file-operation') { var outMsg = { @@ -172,7 +172,7 @@ class Session { try { if (msg.operation === 'created') { outMsg.operation = 'created'; - this.notifyAll(c, outMsg); + this.notifyAll(io, outMsg); } else if (msg.operation === 'moved') { outMsg.operation = 'moved'; var promises = []; @@ -195,7 +195,7 @@ class Session { })); }); Promise.all(promises).then(function() { - self.notifyAll(c, outMsg); + self.notifyAll(io, outMsg); }); } else if (msg.operation === 'deleted') { outMsg.operation = 'deleted'; @@ -207,25 +207,25 @@ class Session { delete self.docs[from]; } }); - this.notifyAll(c, outMsg); + this.notifyAll(io, outMsg); } else if(msg.operation === 'copied'){ outMsg.operation = 'copied'; - this.notifyAll(c, outMsg); + this.notifyAll(io, outMsg); } else { - c.send(JSON.stringify({ + io.send(JSON.stringify({ type: 'error', error: 'Invalid file operation: ' + msg.operation })); } } catch (ex) { - c.send(JSON.stringify({ + io.send(JSON.stringify({ type: 'error', error: 'Invalid operation.' })); console.error(ex); } } else { - c.send(JSON.stringify({ + io.send(JSON.stringify({ type: 'error', error: 'Unknown message type: ' + msg.type })); @@ -236,14 +236,14 @@ class Session { /** * Join a document * - * @param {WebSocket} c + * @param {WebSocket} io * @param {Object} msg * @param {Client} client * @param {string} doc */ - joinDocument(c, msg, client, doc) { + joinDocument(io, msg, client, doc) { if (client.doc && this.docs[client.doc]) { - this.leaveDocument(c, msg, client); + this.leaveDocument(io, msg, client); } // if we don't have the document, let's start it up. if (!this.docs[doc]) { @@ -251,24 +251,24 @@ class Session { this.docs[doc] = new Document(doc, this.sessionId); this.docs[doc].startOT() .then(function() { - self.docs[doc].onmessage(c, msg, client); + self.docs[doc].onmessage(io, msg, client); }); } else { - this.docs[doc].onmessage(c, msg, client); + this.docs[doc].onmessage(io, msg, client); } } /** * Leave the client's document * - * @param {WebSocket} c + * @param {WebSocket} io * @param {Object} msg * @param {Client} client */ - leaveDocument(c, msg, client) { + leaveDocument(io, msg, client) { var self = this; var doc = client.doc; - this.docs[doc].leaveDocument(c, msg.clientId, function(lastPerson) { + this.docs[doc].leaveDocument(io, msg.clientId, function(lastPerson) { if (lastPerson) { self.docs[doc].destroy(); delete self.docs[doc]; @@ -280,15 +280,15 @@ class Session { /** * Send message to all clients * - * @param {WebSocket} c + * @param {WebSocket} io * @param {Object} msg * @param {boolean} [includeSender=false] */ - notifyAll(c, msg, includeSender) { + notifyAll(io, msg, includeSender) { includeSender = !!includeSender; var msgStr = JSON.stringify(msg); this.clients.forEach(function(client, conn) { - if (conn === c && !includeSender) { + if (conn === io && !includeSender) { return; } try { diff --git a/modules/orionode.collab.hub/session_manager.js b/modules/orionode.collab.hub/session_manager.js index 299999410a..f1bd0898b7 100644 --- a/modules/orionode.collab.hub/session_manager.js +++ b/modules/orionode.collab.hub/session_manager.js @@ -41,7 +41,7 @@ class SessionManager { * Add a connection to session * * @param {string} sessionId - * @param {WebSocket} ws + * @param {Socket.io} io * @param {string} clientId * @param {string} name * @@ -49,7 +49,7 @@ class SessionManager { * * @return {Promise} */ - addConnection(sessionId, ws, clientId, name) { + addConnection(sessionId, io, clientId, name) { var self = this; return new Promise(function(resolve, reject) { var session = self._sessions[sessionId]; @@ -77,7 +77,7 @@ class SessionManager { // Add new session session = new Session(sessionId); self._sessions[sessionId] = session; - self.addConnectionToSession(session, ws, new Client(clientId, name)); + self.addConnectionToSession(session, io, new Client(clientId, name)); self._sessionWaitingClients[sessionId].forEach(function(deferred) { deferred.resolve(); }); @@ -86,7 +86,7 @@ class SessionManager { }); } } else { - self.addConnectionToSession(session, ws, new Client(clientId, name)); + self.addConnectionToSession(session, io, new Client(clientId, name)); resolve(); } }); @@ -96,29 +96,29 @@ class SessionManager { * Add a connection to an existing session * * @param {Session} session - * @param {WebSocket} ws + * @param {Socket.io} io * @param {Client} client */ - addConnectionToSession(session, ws, client) { + addConnectionToSession(session, io, client) { var self = this; - session.connectionJoined(ws, client); + session.connectionJoined(io, client); - ws.on('message', function(msg) { + io.on('message', function(msg) { var msgObj; try { msgObj = JSON.parse(msg); } catch(ex) { - ws.send(JSON.stringify({ + io.send(JSON.stringify({ type: 'error', error: 'Invalid JSON.' })); return; } - session.onmessage(ws, msgObj); + session.onmessage(io, msgObj); }); - ws.on('close', function(msg) { - session.connectionLeft(ws, function(empty) { + io.on('disconnect', function(msg) { + session.connectionLeft(io, function(empty) { if (empty) { delete self._sessions[session.sessionId]; } From 2c77da95988c250cb629d36ee556dba01d325a3b Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 23 Jun 2017 15:20:54 -0400 Subject: [PATCH 29/37] clean the configurations Signed-off-by: Sidney --- bundles/org.eclipse.orion.client.ui/web/defaults.pref | 8 +++----- modules/orionode/orion.conf | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index 5809001828..122d31be54 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -10,8 +10,6 @@ "edit/content/imageViewerPlugin.html":true, "edit/content/jsonEditorPlugin.html":true, "cfui/plugins/cFPlugin.html":true, - "cfui/plugins/cFDeployPlugin.html":true, - "collab/plugins/collabPlugin.html": true - }, - "/collab": { "hubUrl": "http://localhost:8082/" } -} \ No newline at end of file + "cfui/plugins/cFDeployPlugin.html":true + } +} diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 79fad0ec99..6d8a983312 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -19,7 +19,7 @@ orion.oauth.google.client= orion.oauth.google.secret= -orion.single.user=false +orion.single.user=true orion.buildId= orion.autoUpdater.url= @@ -62,5 +62,5 @@ additional.modules.path= shutdown.timeout=10000 #The attributes for collaboration -orion.collab.enabled=true -orion.jwt.secret=orion collab \ No newline at end of file +orion.collab.enabled= +orion.jwt.secret= \ No newline at end of file From a3451d2067e47909a5957f22d91a1aeb9a0d7b44 Mon Sep 17 00:00:00 2001 From: Rickus Senekal Date: Tue, 27 Jun 2017 09:39:33 -0700 Subject: [PATCH 30/37] Clean collaboration - Setup Sidney's commit for collaboration - Base collaboration works without highlight --- .../web/defaults.pref | 30 +++++++++++-------- modules/orionode.collab.hub/config.js | 2 +- modules/orionode/endpoint.json | 8 +++++ modules/orionode/orion.conf | 6 ++-- 4 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 modules/orionode/endpoint.json diff --git a/bundles/org.eclipse.orion.client.ui/web/defaults.pref b/bundles/org.eclipse.orion.client.ui/web/defaults.pref index 122d31be54..045991215c 100644 --- a/bundles/org.eclipse.orion.client.ui/web/defaults.pref +++ b/bundles/org.eclipse.orion.client.ui/web/defaults.pref @@ -1,15 +1,19 @@ { - "/plugins":{ - "plugins/orionSharedWorker.js": true, - "shell/plugins/shellPagePlugin.html": true, - "plugins/site/sitePlugin.html": true, - "plugins/languages/json/jsonPlugin.html": true, - "git/plugins/gitPlugin.html":true, - "webtools/plugins/webToolsPlugin.html":true, - "javascript/plugins/javascriptPlugin.html":true, - "edit/content/imageViewerPlugin.html":true, - "edit/content/jsonEditorPlugin.html":true, - "cfui/plugins/cFPlugin.html":true, - "cfui/plugins/cFDeployPlugin.html":true - } + "/plugins": { + "plugins/orionSharedWorker.js": true, + "shell/plugins/shellPagePlugin.html": true, + "plugins/site/sitePlugin.html": true, + "plugins/languages/json/jsonPlugin.html": true, + "git/plugins/gitPlugin.html": true, + "webtools/plugins/webToolsPlugin.html": true, + "javascript/plugins/javascriptPlugin.html": true, + "edit/content/imageViewerPlugin.html": true, + "edit/content/jsonEditorPlugin.html": true, + "cfui/plugins/cFPlugin.html": true, + "cfui/plugins/cFDeployPlugin.html": true, + "collab/plugins/collabPlugin.html": true + }, + "/collab": { + "hubUrl": "http://localhost:8082/" + } } diff --git a/modules/orionode.collab.hub/config.js b/modules/orionode.collab.hub/config.js index 94bf66e6c1..4cbe6d1bdb 100644 --- a/modules/orionode.collab.hub/config.js +++ b/modules/orionode.collab.hub/config.js @@ -17,7 +17,7 @@ module.exports = { /** * Orion url */ - 'orion': "http://localhost:8084/", + 'orion': "http://localhost:8081/", /** * Load url. Make sure end with / */ diff --git a/modules/orionode/endpoint.json b/modules/orionode/endpoint.json new file mode 100644 index 0000000000..d6e05ccf36 --- /dev/null +++ b/modules/orionode/endpoint.json @@ -0,0 +1,8 @@ +[{ + "endpoint": "/sharedWorkspace", + "module": "./lib/sharedWorkspace", + "extraOptions": { + "root": "/sharedWorkspace/tree/file", + "fileRoot": "/file" + } +}] diff --git a/modules/orionode/orion.conf b/modules/orionode/orion.conf index 6d8a983312..4e28371873 100644 --- a/modules/orionode/orion.conf +++ b/modules/orionode/orion.conf @@ -19,7 +19,7 @@ orion.oauth.google.client= orion.oauth.google.secret= -orion.single.user=true +orion.single.user=false orion.buildId= orion.autoUpdater.url= @@ -62,5 +62,5 @@ additional.modules.path= shutdown.timeout=10000 #The attributes for collaboration -orion.collab.enabled= -orion.jwt.secret= \ No newline at end of file +orion.collab.enabled=true +orion.jwt.secret=orion collab From 4662117497f922e399a939c72fb6165beba201ce Mon Sep 17 00:00:00 2001 From: Arshi Annafi Date: Tue, 27 Jun 2017 10:15:38 -0700 Subject: [PATCH 31/37] Add Collab Highlight Feature Added collab highlight implementation to Sidney's clean collaboration pr/46 --- .../web/orion/collab/otAdapters.js | 32 ++- .../web/orion/editor/textView.js | 237 +++++++++++++++++- 2 files changed, 244 insertions(+), 25 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js index e7ecc0240a..e76dd6dadb 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js @@ -40,7 +40,7 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function }; OrionSocketAdapter.prototype.constructor = OrionSocketAdapter; - + /** * Send authenticate message */ @@ -286,7 +286,7 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function case 'client-updated': this.collabClient.addOrUpdatePeer(new CollabPeer(msg.clientId, msg.name, msg.color)); if (msg.location) { - this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + msg.location, msg.editing); + this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + '/file/' + msg.location, msg.editing); } else { this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, '', msg.editing); } @@ -552,7 +552,7 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function }; OrionEditorAdapter.prototype.getSelection = function () { - return ot.Selection.createCursor(this.editor.getSelection().start); + return new ot.Selection([new ot.Selection.Range(this.editor.getSelection().start, this.editor.getSelection().end)]); }; OrionEditorAdapter.prototype.setSelection = function (selection) { @@ -586,15 +586,6 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function var lastLine = this.model.getLineCount()-1; var lineStartOffset = this.model.getLineStart(currLine); - if (offset) { - //decide whether or not it is worth sending (if line has changed or needs updating). - if (currLine !== this.myLine || currLine === lastLine || currLine === 0) { - // Send this change - } else { - return; - } - } - this.myLine = currLine; // Self-tracking @@ -608,7 +599,12 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function if (this.changeInProgress) { this.selectionChanged = true; } else { - this.trigger('selectionChange'); + if(!this.editor._listener.mouseDown){ + // Trigger 'selectionChange' (send messges) only if mouse is up. + // This prevents from sending multiple messages + // while user is selecting. + this.trigger('selectionChange'); + } } }; @@ -624,7 +620,10 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function var peer = this.collabClient.getPeer(clientId); var name = peer ? peer.name : undefined; color = peer ? peer.color : color; + this.updateLineAnnotation(clientId, selection, name, color); + this.updateHighlight(clientId, selection, name, color); + var self = this; return { clear: function() { @@ -633,6 +632,13 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function }; }; + OrionEditorAdapter.prototype.updateHighlight = function(id, selection, name, color, force) { + // Extracting 'highlight' information out of 'selection' object + var ranges = selection.ranges[0]; + + this.collabClient.textView._addHighlight(id, color, ranges.anchor, ranges.head); + } + OrionEditorAdapter.prototype.updateLineAnnotation = function(id, selection, name, color, force) { force = !!force; name = name || 'Unknown'; diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index 84abdfad51..2aeb248f78 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -651,9 +651,8 @@ define("orion/editor/textView", [ //$NON-NLS-1$ } TextLine.prototype = /** @lends orion.editor.TextLine.prototype */ { /** @private */ - create: function(_parent, div, drawing) { + create: function(_parent, div) { if (this._lineDiv) { return; } - this.drawing = drawing; var child = this._lineDiv = this._createLine(_parent, div, this.lineIndex); child._line = this; return child; @@ -890,11 +889,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ child.innerHTML = style.html; child.ignore = true; } else if (style && style.node) { - if (this.drawing) { - child.appendChild(style.node); - } else { - child.appendChild(style.node.cloneNode(true)); - } + child.appendChild(style.node); child.ignore = true; } else if (style && style.bidi) { child.ignore = true; @@ -5967,6 +5962,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ /* Destroy DOM */ this._domSelection = null; + this._peerHighlight = null; this._clipboardDiv = null; this._rootDiv = null; this._scrollDiv = null; @@ -7370,6 +7366,14 @@ define("orion/editor/textView", [ //$NON-NLS-1$ } } }, + /** + * Update highlights + */ + _updatePeerHighlight: function() { + if (this._peerHighlight) { + this._peerHighlight.update(); + } + }, _update: function(hScrollOnly) { if (this._redrawCount > 0) { return; } if (this._updateTimer) { @@ -7486,14 +7490,14 @@ define("orion/editor/textView", [ //$NON-NLS-1$ var frag = doc.createDocumentFragment(); for (lineIndex=lineStart; lineIndex<=lineEnd; lineIndex++) { if (!child || child.lineIndex > lineIndex) { - new TextLine(this, lineIndex).create(frag, null, true); + new TextLine(this, lineIndex).create(frag, null); } else { if (frag.firstChild) { clientDiv.insertBefore(frag, child); frag = doc.createDocumentFragment(); } if (child && child.lineChanged) { - child = new TextLine(this, lineIndex).create(frag, child, true); + child = new TextLine(this, lineIndex).create(frag, child); child.lineChanged = false; } child = this._getLineNext(child); @@ -7752,7 +7756,9 @@ define("orion/editor/textView", [ //$NON-NLS-1$ } } } + this._updateDOMSelection(); + this._updatePeerHighlight(); if (needUpdate) { var ensureCaretVisible = this._ensureCaretVisible; @@ -7961,10 +7967,217 @@ define("orion/editor/textView", [ //$NON-NLS-1$ this.redraw(); this._resetLineWidth(); } - } - };//end prototype + }, + /** + * Called from 'otAdapter.js'. Initiates work on highlight on the 'textView' + * + * @param {String} id - Peer id + * @param {String} color - Color that will be used for highlight peer's selection + * @param {Int} start - Char where peer's selection started + * @param {Int} end - Char where peer's selection ended + */ + _addHighlight: function(id, color, start, end) { + // if the instance of 'textView' doesnt have an instance of PeerHighlight, + // then create one + if (this._peerHighlight === undefined) { + this._peerHighlight = new PeerHighlight(this); + } + + // Add highlight + this._peerHighlight.addHighlight(id, color, start, end); + } + }; + + /** + * This class manages collab peer's highlights on user's screen + */ + function PeerHighlight(view) { + // List of class variables and functions + // + // Variables + // _view + // _divs + // _highlights + // + // Constructor + // PeerHighlight(view) + // + // Function + // addHighlight + // update + // destroy + // + + /** + * Reference to parent 'textView' instance + */ + this._view = view; + + /** + * Array of all child 'divs' that (together) creates highlight + */ + this._divs = []; + + /** + * Data structure that stores all highlights. + * This also enforces rendering of only one highlight at a time. + * Even though collab peers can highlight multiple sections on their side. + * + * _highlights = [ + * idOfUser1: { + * 'color': 'hexString', + * 'start': int, + * 'end': int + * }, + * idOfUser2: { + * ... + * }, + * ... + * ] + */ + this._highlights = []; + + } + + /** + * This function adds peer's highlight on the screen + */ + PeerHighlight.prototype.addHighlight = function(id, color, start, end) { + + // Add/update data structure + this._highlights[id] = { + 'color': color, + 'start': start, + 'end': end + }; + + // Update the views + this.update(); + } + + /** + * This function updates highlight views + */ + PeerHighlight.prototype.update = function() { + // CSS Variables + var zIndexOfHighlight = 2; + var highlightOpacity = 0.25; + + // Get references and store them in local variables + var model = this._view._model; + var parent = this._view._clipDiv; + var lineOffsets = model._model._lineOffsets; + + // Remove all highlights if there exist any + this._divs.forEach(function(div) { + div.remove(); + }); + this._divs = []; + + // For all highlights + for (var h in this._highlights) { + + // store start, end, and color in local variable + var start = this._highlights[h].start; + var end = this._highlights[h].end; + var color = this._highlights[h].color; + + // Dimension constants + var lineHeight = this._view._getLineHeight(); + var charWidth = 7.2246; + + // get line number of 'start' and 'end' (char location) of the highlight + var startLineNumber = model.getLineAtOffset(start); + var endLineNumber = model.getLineAtOffset(end); + // Convert them from 'line number' to 'px' + var startpx = startLineNumber * lineHeight; + var endpx = endLineNumber * lineHeight; + + // Relative position in the document + // that is currently at the top edge of the view + var toppx = this._view.getTopPixel(); + + var divVoffset = 0; + if (toppx === 0) { + divVoffset = 4; + } + + // DIV 1 - for first line selection + var div = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ + div.style.position = 'absolute'; + div.style.pointerEvents = 'none'; + div.style.backgroundColor = color; + div.style.opacity = highlightOpacity; + div.style.zIndex = zIndexOfHighlight; + div.style.height = this._view._getLineHeight() + 'px'; + div.style.top = (startpx - toppx + divVoffset) + 'px'; + div.style.left = ((charWidth * (start - lineOffsets[startLineNumber])) + 2) + 'px'; + + if (startLineNumber === endLineNumber) { + // If selection is in one line + div.style.width = ((end - start) * charWidth) + 'px'; + } else { + // If selection spans multiple lines, + // make div1 select only first line + // and use div 2 for middle block and div 3 for trailing end + div.style.width = '100%'; + } + // End of div 1 + + // DIV 2 - for middle block + var div2 = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ + div2.style.position = 'absolute'; + div2.style.pointerEvents = 'none'; + div2.style.backgroundColor = color; + div2.style.opacity = highlightOpacity; + div2.style.zIndex = zIndexOfHighlight; + div2.style.height = ((endpx - toppx) - (startpx - toppx) - lineHeight) + 'px'; + div2.style.top = (startpx - toppx + lineHeight + divVoffset) + 'px'; + div2.style.left = '2px'; + div2.style.width = '100%'; + // End of div 2 + + // DIV 3 - for trailing end + var div3 = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ + div3.style.position = 'absolute'; + div3.style.pointerEvents = 'none'; + div3.style.backgroundColor = color; + div3.style.opacity = highlightOpacity; + div3.style.zIndex = zIndexOfHighlight; + div3.style.height = this._view._getLineHeight() + 'px'; + div3.style.top = (endpx - toppx + divVoffset) + 'px'; + div3.style.left = '2px'; + div3.style.width = (charWidth * (end - lineOffsets[endLineNumber])) + 'px'; + // End of div 3 + + // Append Child + parent.appendChild(div); + this._divs.push(div); + + parent.appendChild(div2); + this._divs.push(div2); + + if (startLineNumber !== endLineNumber) { + parent.appendChild(div3); + this._divs.push(div3); + } + + } // end of loop + + } // End of PeerHighlight.update() + + PeerHighlight.prototype.destroy = function() { + // Remove all highlight divs + this._divs.forEach(function(div) { + div.remove(); + }); + + this._view = null; + this._divs = null; + this._highlights = null; + } + mEventTarget.EventTarget.addMixin(TextView.prototype); return {TextView: TextView}; }); - From ceb2f800fe34ec5af8357751107b667fbb955b8c Mon Sep 17 00:00:00 2001 From: Rickus Senekal Date: Tue, 27 Jun 2017 11:32:05 -0700 Subject: [PATCH 32/37] Refactoring PeerHighlight - Move div creation in a class function --- .../web/orion/editor/textView.js | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index 2aeb248f78..822a121a10 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -8002,8 +8002,9 @@ define("orion/editor/textView", [ //$NON-NLS-1$ // Constructor // PeerHighlight(view) // - // Function + // Functions // addHighlight + // createHighlightDiv // update // destroy // @@ -8055,14 +8056,26 @@ define("orion/editor/textView", [ //$NON-NLS-1$ this.update(); } + PeerHighlight.prototype.createHighlightDiv = function(color) { + var div = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ + + div.style.position = 'absolute'; + + // Styles + div.style.backgroundColor = color; + div.style.opacity = 0.25; + div.style.zIndex = 2; + + // Let clicks fall through the div + div.style.pointerEvents = 'none'; + + return div; + } + /** * This function updates highlight views */ PeerHighlight.prototype.update = function() { - // CSS Variables - var zIndexOfHighlight = 2; - var highlightOpacity = 0.25; - // Get references and store them in local variables var model = this._view._model; var parent = this._view._clipDiv; @@ -8103,13 +8116,8 @@ define("orion/editor/textView", [ //$NON-NLS-1$ } // DIV 1 - for first line selection - var div = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ - div.style.position = 'absolute'; - div.style.pointerEvents = 'none'; - div.style.backgroundColor = color; - div.style.opacity = highlightOpacity; - div.style.zIndex = zIndexOfHighlight; - div.style.height = this._view._getLineHeight() + 'px'; + var div = this.createHighlightDiv(color); + div.style.height = lineHeight + 'px'; div.style.top = (startpx - toppx + divVoffset) + 'px'; div.style.left = ((charWidth * (start - lineOffsets[startLineNumber])) + 2) + 'px'; @@ -8120,34 +8128,24 @@ define("orion/editor/textView", [ //$NON-NLS-1$ // If selection spans multiple lines, // make div1 select only first line // and use div 2 for middle block and div 3 for trailing end - div.style.width = '100%'; + div.style.width = '100%'; // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px } // End of div 1 // DIV 2 - for middle block - var div2 = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ - div2.style.position = 'absolute'; - div2.style.pointerEvents = 'none'; - div2.style.backgroundColor = color; - div2.style.opacity = highlightOpacity; - div2.style.zIndex = zIndexOfHighlight; + var div2 = this.createHighlightDiv(color); div2.style.height = ((endpx - toppx) - (startpx - toppx) - lineHeight) + 'px'; div2.style.top = (startpx - toppx + lineHeight + divVoffset) + 'px'; div2.style.left = '2px'; - div2.style.width = '100%'; + div2.style.width = '100%'; // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px // End of div 2 // DIV 3 - for trailing end - var div3 = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ - div3.style.position = 'absolute'; - div3.style.pointerEvents = 'none'; - div3.style.backgroundColor = color; - div3.style.opacity = highlightOpacity; - div3.style.zIndex = zIndexOfHighlight; - div3.style.height = this._view._getLineHeight() + 'px'; + var div3 = this.createHighlightDiv(color); + div3.style.height = lineHeight + 'px'; div3.style.top = (endpx - toppx + divVoffset) + 'px'; div3.style.left = '2px'; - div3.style.width = (charWidth * (end - lineOffsets[endLineNumber])) + 'px'; + div3.style.width = (charWidth * (end - lineOffsets[endLineNumber])) + 'px'; // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px // End of div 3 // Append Child From 1dc0b09319b37478f5154b26d15b348ae1a34a3a Mon Sep 17 00:00:00 2001 From: Arshi Annafi Date: Tue, 27 Jun 2017 15:57:33 -0700 Subject: [PATCH 33/37] Refactoring PeerHighlight - Eliminate redundant: - Creation of highlight divs - Reassignment of constants variables --- .../web/orion/editor/textView.js | 114 ++++++++++-------- 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index 822a121a10..ff22010223 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -8081,23 +8081,29 @@ define("orion/editor/textView", [ //$NON-NLS-1$ var parent = this._view._clipDiv; var lineOffsets = model._model._lineOffsets; - // Remove all highlights if there exist any - this._divs.forEach(function(div) { - div.remove(); - }); - this._divs = []; - // For all highlights - for (var h in this._highlights) { + // Dimension constants + var lineHeight = this._view._getLineHeight(); + var charWidth = 7.2246; + /** + * Relative position in the document + * that is currently at the top edge of the view + */ + var toppx = this._view.getTopPixel(); + var divVoffset = 0; + if (toppx === 0) { + divVoffset = 4; + } + // End of Dimension constants + - // store start, end, and color in local variable - var start = this._highlights[h].start; - var end = this._highlights[h].end; - var color = this._highlights[h].color; + // For all peer highlights + for (var userid in this._highlights) { - // Dimension constants - var lineHeight = this._view._getLineHeight(); - var charWidth = 7.2246; + // store start, end, and color of peer in local variable + var start = this._highlights[userid].start; + var end = this._highlights[userid].end; + var color = this._highlights[userid].color; // get line number of 'start' and 'end' (char location) of the highlight var startLineNumber = model.getLineAtOffset(start); @@ -8106,61 +8112,69 @@ define("orion/editor/textView", [ //$NON-NLS-1$ var startpx = startLineNumber * lineHeight; var endpx = endLineNumber * lineHeight; - // Relative position in the document - // that is currently at the top edge of the view - var toppx = this._view.getTopPixel(); - var divVoffset = 0; - if (toppx === 0) { - divVoffset = 4; + // Reference to the 3 divs + var div1, div2, div3; + if (this._divs[userid]){ + // Get divs if divs already exist for the peer + div1 = this._divs[userid].div1; + div2 = this._divs[userid].div2; + div3 = this._divs[userid].div3; + } else { + // Create divs if they dont already exist for this peer + div1 = this.createHighlightDiv(color); + div2 = this.createHighlightDiv(color); + div3 = this.createHighlightDiv(color); + + // Append Child + this._divs[userid] = {} + this._divs[userid].div1 = div1; + this._divs[userid].div2 = div2; + this._divs[userid].div3 = div3; + parent.appendChild(div1); + parent.appendChild(div2); + parent.appendChild(div3); } - // DIV 1 - for first line selection - var div = this.createHighlightDiv(color); - div.style.height = lineHeight + 'px'; - div.style.top = (startpx - toppx + divVoffset) + 'px'; - div.style.left = ((charWidth * (start - lineOffsets[startLineNumber])) + 2) + 'px'; - if (startLineNumber === endLineNumber) { - // If selection is in one line - div.style.width = ((end - start) * charWidth) + 'px'; - } else { - // If selection spans multiple lines, - // make div1 select only first line - // and use div 2 for middle block and div 3 for trailing end - div.style.width = '100%'; // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px - } + // ******************* Highlight div dimension logic ****************** // + // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px + + // DIV 1 - for first line selection + div1.style.height = lineHeight + 'px'; + div1.style.top = (startpx - toppx + divVoffset) + 'px'; + div1.style.left = ((charWidth * (start - lineOffsets[startLineNumber])) + 2) + 'px'; // End of div 1 // DIV 2 - for middle block - var div2 = this.createHighlightDiv(color); - div2.style.height = ((endpx - toppx) - (startpx - toppx) - lineHeight) + 'px'; div2.style.top = (startpx - toppx + lineHeight + divVoffset) + 'px'; div2.style.left = '2px'; - div2.style.width = '100%'; // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px + div2.style.width = '100%'; // End of div 2 // DIV 3 - for trailing end - var div3 = this.createHighlightDiv(color); div3.style.height = lineHeight + 'px'; div3.style.top = (endpx - toppx + divVoffset) + 'px'; div3.style.left = '2px'; - div3.style.width = (charWidth * (end - lineOffsets[endLineNumber])) + 'px'; // TODO Fix: account for 2px on the right. i.e. width = 100% - 2px // End of div 3 - // Append Child - parent.appendChild(div); - this._divs.push(div); - - parent.appendChild(div2); - this._divs.push(div2); - - if (startLineNumber !== endLineNumber) { - parent.appendChild(div3); - this._divs.push(div3); + // If peer's whole selection is on one line + if (startLineNumber === endLineNumber) { + div1.style.width = ((end - start) * charWidth) + 'px'; + div2.style.height = '0px'; + div3.style.width = '0px'; + } else { + // If peer's selection spans multiple lines: draw... + // div1 only on first line, + // div2 for the middle block, and + // div3 for the trailing end of the peer's selection + div1.style.width = '100%'; + div2.style.height = ((endpx - toppx) - (startpx - toppx) - lineHeight) + 'px'; + div3.style.width = (charWidth * (end - lineOffsets[endLineNumber])) + 'px'; } + // *************** End of Highlight div dimension logic *************** // - } // end of loop + } // end of for each peer highlight loop } // End of PeerHighlight.update() From b4589ee780c9d957336b2c5fe260f2adf6abdbec Mon Sep 17 00:00:00 2001 From: Arshi Annafi Date: Thu, 29 Jun 2017 12:59:17 -0700 Subject: [PATCH 34/37] Update 'onDestroy' method Reflects the current data structure --- .../web/orion/editor/textView.js | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index ff22010223..4ec813049f 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -8016,6 +8016,19 @@ define("orion/editor/textView", [ //$NON-NLS-1$ /** * Array of all child 'divs' that (together) creates highlight + * + * _divs = [ + * userId1: { + * div1:
    + * div2:
    + * div3:
    + * }, + * userId2: { + * ... + * }, + * ... + * ] // end of _divs + * */ this._divs = []; @@ -8180,9 +8193,13 @@ define("orion/editor/textView", [ //$NON-NLS-1$ PeerHighlight.prototype.destroy = function() { // Remove all highlight divs - this._divs.forEach(function(div) { - div.remove(); - }); + + for (var peerId in this._divs) { // Foreach peer + for (var divNum in this._divs[peerId]) { // for each highlight div + // remove the divs (max 3 divs) + this._divs[peerId][divNum].remove(); + } + } this._view = null; this._divs = null; From 5e6c9d1f19dd0e020c8608b875ee9ed3494d5103 Mon Sep 17 00:00:00 2001 From: Arshi Annafi Date: Thu, 29 Jun 2017 13:44:21 -0700 Subject: [PATCH 35/37] Fix: remove highlight when peer leaves Removes highlight when peers leave. E.g. exit browser without deselecting the text. --- .../web/orion/collab/otAdapters.js | 1 + .../web/orion/editor/textView.js | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js index e76dd6dadb..2ebfcb156e 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js @@ -294,6 +294,7 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function case 'client-left': this.collabClient.removePeer(msg.clientId); + this.collabClient.textView._removePeerHighlight(msg.clientId); break; default: diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index 4ec813049f..67cde91975 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -7985,6 +7985,14 @@ define("orion/editor/textView", [ //$NON-NLS-1$ // Add highlight this._peerHighlight.addHighlight(id, color, start, end); + }, + /** + * This function is called when a peer leaves. This function calls + * the 'removePeer' funtion of the 'PeerHighlight' instance of + * this TextView. + */ + _removePeerHighlight: function(id) { + this._peerHighlight.removePeer(id); } }; @@ -8069,6 +8077,21 @@ define("orion/editor/textView", [ //$NON-NLS-1$ this.update(); } + /** + * This function removes: + * - the peer (from '_highlights') and + * - The 3 div elements (in '_divs') that presents highlight of the peer + */ + PeerHighlight.prototype.removePeer = function(peerId) { + // Remove peer from data structure + delete this._highlights.peerId; + + // Remove divs + for (var divNum in this._divs[peerId]) { + this._divs[peerId][divNum].remove(); + } + } + PeerHighlight.prototype.createHighlightDiv = function(color) { var div = util.createElement(this._view._parent.ownerDocument, "div"); //$NON-NLS-1$ @@ -8193,12 +8216,8 @@ define("orion/editor/textView", [ //$NON-NLS-1$ PeerHighlight.prototype.destroy = function() { // Remove all highlight divs - - for (var peerId in this._divs) { // Foreach peer - for (var divNum in this._divs[peerId]) { // for each highlight div - // remove the divs (max 3 divs) - this._divs[peerId][divNum].remove(); - } + for (var peerId in this._divs) { + this.removePeer(peerId); } this._view = null; From 279ac83fd971568065228e91f34a00d8ab46a90b Mon Sep 17 00:00:00 2001 From: Arshi Annafi Date: Fri, 4 Aug 2017 10:54:40 -0700 Subject: [PATCH 36/37] Restore altered code Collab Highlight was originally built on an older commit. When collab highlight code was copied to commit cb48ab, some code were altered as a side-effect. In this commit, commit cb48ab (base) and 5e6c9d1 (head) was diff-ed to find and restore altered code. Restoring code from 1. commit cb48ab86539f381f664bd21f235cce6c3b71a9bf Author: libing wang Date: Thu Jun 22 15:04:33 2017 -0400 Subj: Bug 514792 - AnnotationStyler to merge html and DOM node style in com... Side-effect: Around line `create: function(_parent, div, drawing) {`, all instance of 'drawing' was removed In this commit: restored 'drawing' 2. commit c81fb19085b27b5e87a7d8a325fa0506c1ac1960 Author: Sidney Date: Thu Jun 15 16:53:04 2017 -0400 Subj: cleaned 50% garbage code Side-effect: '/file/' came back. In this commit: removed '/file/' --- .../web/orion/collab/otAdapters.js | 2 +- .../web/orion/editor/textView.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js index 2ebfcb156e..1fca6ca952 100644 --- a/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js +++ b/bundles/org.eclipse.orion.client.collab/web/orion/collab/otAdapters.js @@ -286,7 +286,7 @@ define(['orion/collab/collabPeer', 'orion/collab/ot', 'orion/uiUtils'], function case 'client-updated': this.collabClient.addOrUpdatePeer(new CollabPeer(msg.clientId, msg.name, msg.color)); if (msg.location) { - this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + '/file/' + msg.location, msg.editing); + this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, contextPath + msg.location, msg.editing); } else { this.collabClient.addOrUpdateCollabFileAnnotation(msg.clientId, '', msg.editing); } diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index 67cde91975..f476f7e835 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -651,8 +651,9 @@ define("orion/editor/textView", [ //$NON-NLS-1$ } TextLine.prototype = /** @lends orion.editor.TextLine.prototype */ { /** @private */ - create: function(_parent, div) { + create: function(_parent, div, drawing) { if (this._lineDiv) { return; } + this.drawing = drawing; var child = this._lineDiv = this._createLine(_parent, div, this.lineIndex); child._line = this; return child; @@ -889,7 +890,11 @@ define("orion/editor/textView", [ //$NON-NLS-1$ child.innerHTML = style.html; child.ignore = true; } else if (style && style.node) { - child.appendChild(style.node); + if (this.drawing) { + child.appendChild(style.node); + } else { + child.appendChild(style.node.cloneNode(true)); + } child.ignore = true; } else if (style && style.bidi) { child.ignore = true; @@ -7490,14 +7495,14 @@ define("orion/editor/textView", [ //$NON-NLS-1$ var frag = doc.createDocumentFragment(); for (lineIndex=lineStart; lineIndex<=lineEnd; lineIndex++) { if (!child || child.lineIndex > lineIndex) { - new TextLine(this, lineIndex).create(frag, null); + new TextLine(this, lineIndex).create(frag, null, true); } else { if (frag.firstChild) { clientDiv.insertBefore(frag, child); frag = doc.createDocumentFragment(); } if (child && child.lineChanged) { - child = new TextLine(this, lineIndex).create(frag, child); + child = new TextLine(this, lineIndex).create(frag, child, true); child.lineChanged = false; } child = this._getLineNext(child); From 435ee92b9a292d929bef9082269397befe49c5ae Mon Sep 17 00:00:00 2001 From: Rickus Senekal Date: Fri, 4 Aug 2017 12:19:09 -0700 Subject: [PATCH 37/37] Fix: Collab Highlight width inconsistency Collab highlight width was inconsistent across different browsers. The width would vary on different browsers as peers select long texts on the same line. This is due to the use of a calculated constant in the code rather than using environment variables that we were unaware of at the time. Thanks to Silenio, we found a variable to fixes this issue. --- .../web/orion/editor/textView.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js index f476f7e835..4b564fdfb0 100644 --- a/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js +++ b/bundles/org.eclipse.orion.client.editor/web/orion/editor/textView.js @@ -5531,7 +5531,8 @@ define("orion/editor/textView", [ //$NON-NLS-1$ _parent.appendChild(div1); div1.innerHTML = newArray(2).join("a"); //$NON-NLS-1$ rect1 = div1.getBoundingClientRect(); - charWidth = Math.ceil(rect1.right - rect1.left); + charWidth_float = rect1.right - rect1.left; + charWidth = Math.ceil(charWidth_float); if (this._wrapOffset || this._marginOffset) { div1.innerHTML = newArray(this._wrapOffset + 1 + (util.isWebkit ? 0 : 1)).join(" "); //$NON-NLS-1$ rect1 = div1.getBoundingClientRect(); @@ -5550,6 +5551,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ scrollWidth: scrollWidth, wrapWidth: wrapWidth, marginWidth: marginWidth, + charWidth_float: charWidth_float, charWidth: charWidth, invalid: invalid }; @@ -8125,7 +8127,7 @@ define("orion/editor/textView", [ //$NON-NLS-1$ // Dimension constants var lineHeight = this._view._getLineHeight(); - var charWidth = 7.2246; + var charWidth = this._view._calculateMetrics().charWidth_float; /** * Relative position in the document * that is currently at the top edge of the view