Browse Source

Add support for setting language

Eric Zhang 4 years ago
parent
commit
ff9069eda5
4 changed files with 125 additions and 7 deletions
  1. 48 3
      rustpad-server/src/rustpad.rs
  2. 33 0
      rustpad-server/tests/sockets.rs
  3. 34 4
      src/App.tsx
  4. 10 0
      src/rustpad.ts

+ 48 - 3
rustpad-server/src/rustpad.rs

@@ -9,15 +9,20 @@ use log::{info, warn};
 use operational_transform::OperationSeq;
 use parking_lot::{RwLock, RwLockUpgradableReadGuard};
 use serde::{Deserialize, Serialize};
-use tokio::{sync::Notify, time};
+use tokio::sync::{broadcast, Notify};
+use tokio::time;
 use warp::ws::{Message, WebSocket};
 
 /// The main object for a collaborative session.
-#[derive(Default)]
 pub struct Rustpad {
+    /// State modified by critical sections of the code.
     state: RwLock<State>,
+    /// Incremented to obtain unique user IDs.
     count: AtomicU64,
+    /// Used to notify clients of new text operations.
     notify: Notify,
+    /// Used to inform all clients of metadata updates.
+    update: broadcast::Sender<ServerMsg>,
 }
 
 /// Shared state involving multiple users, protected by a lock.
@@ -25,6 +30,7 @@ pub struct Rustpad {
 struct State {
     operations: Vec<UserOperation>,
     text: String,
+    language: Option<String>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -41,6 +47,8 @@ enum ClientMsg {
         revision: usize,
         operation: OperationSeq,
     },
+    /// Set the language of the editor.
+    SetLanguage(String),
 }
 
 /// A message sent to the client over WebSocket.
@@ -53,6 +61,8 @@ enum ServerMsg {
         start: usize,
         operations: Vec<UserOperation>,
     },
+    /// Broadcasts the current language, last writer wins.
+    Language(String),
 }
 
 impl From<ServerMsg> for Message {
@@ -62,6 +72,18 @@ impl From<ServerMsg> for Message {
     }
 }
 
+impl Default for Rustpad {
+    fn default() -> Self {
+        let (tx, _) = broadcast::channel(1);
+        Self {
+            state: Default::default(),
+            count: Default::default(),
+            notify: Default::default(),
+            update: tx,
+        }
+    }
+}
+
 impl Rustpad {
     /// Handle a connection from a WebSocket.
     pub async fn on_connection(&self, socket: WebSocket) {
@@ -86,8 +108,9 @@ impl Rustpad {
     }
 
     async fn handle_connection(&self, id: u64, mut socket: WebSocket) -> Result<()> {
-        socket.send(ServerMsg::Identity(id).into()).await?;
+        let mut update_rx = self.update.subscribe();
 
+        self.send_initial(id, &mut socket).await?;
         let mut revision: usize = 0;
 
         loop {
@@ -100,6 +123,9 @@ impl Rustpad {
             tokio::select! {
                 _ = &mut sleep => {}
                 _ = self.notify.notified() => {}
+                update = update_rx.recv() => {
+                    socket.send(update?.into()).await?;
+                }
                 result = socket.next() => {
                     match result {
                         None => break,
@@ -114,6 +140,21 @@ impl Rustpad {
         Ok(())
     }
 
+    async fn send_initial(&self, id: u64, socket: &mut WebSocket) -> Result<()> {
+        socket.send(ServerMsg::Identity(id).into()).await?;
+        let mut messages = Vec::new();
+        {
+            let state = self.state.read();
+            if let Some(language) = &state.language {
+                messages.push(ServerMsg::Language(language.clone()));
+            }
+        };
+        for msg in messages {
+            socket.send(msg.into()).await?;
+        }
+        Ok(())
+    }
+
     async fn send_history(&self, start: usize, socket: &mut WebSocket) -> Result<usize> {
         let operations = {
             let state = self.state.read();
@@ -146,6 +187,10 @@ impl Rustpad {
                     .context("invalid edit operation")?;
                 self.notify.notify_waiters();
             }
+            ClientMsg::SetLanguage(language) => {
+                self.state.write().language = Some(language.clone());
+                self.update.send(ServerMsg::Language(language)).ok();
+            }
         }
         Ok(())
     }

+ 33 - 0
rustpad-server/tests/sockets.rs

@@ -195,3 +195,36 @@ async fn test_concurrent_transform() -> Result<()> {
     expect_text(&filter, "foobar", "~rust~henlo").await;
     Ok(())
 }
+
+#[tokio::test]
+async fn test_set_language() -> Result<()> {
+    pretty_env_logger::try_init().ok();
+    let filter = server();
+
+    let mut client = connect(&filter, "foobar").await?;
+    let msg = client.recv().await?;
+    assert_eq!(msg, json!({ "Identity": 0 }));
+
+    let msg = json!({ "SetLanguage": "javascript" });
+    client.send(&msg).await;
+
+    let msg = client.recv().await?;
+    assert_eq!(msg, json!({ "Language": "javascript" }));
+
+    let mut client2 = connect(&filter, "foobar").await?;
+    let msg = client2.recv().await?;
+    assert_eq!(msg, json!({ "Identity": 1 }));
+    let msg = client2.recv().await?;
+    assert_eq!(msg, json!({ "Language": "javascript" }));
+
+    let msg = json!({ "SetLanguage": "python" });
+    client2.send(&msg).await;
+
+    let msg = client.recv().await?;
+    assert_eq!(msg, json!({ "Language": "python" }));
+    let msg = client2.recv().await?;
+    assert_eq!(msg, json!({ "Language": "python" }));
+
+    expect_text(&filter, "foobar", "").await;
+    Ok(())
+}

+ 34 - 4
src/App.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import { set_panic_hook } from "rustpad-wasm";
 import {
   Box,
@@ -48,13 +48,14 @@ function App() {
   const [connection, setConnection] =
     useState<"connected" | "disconnected" | "desynchronized">("disconnected");
   const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
+  const rustpad = useRef<Rustpad>();
 
   useEffect(() => {
     if (editor) {
       const model = editor.getModel()!;
       model.setValue("");
       model.setEOL(0); // LF
-      const rustpad = new Rustpad({
+      rustpad.current = new Rustpad({
         uri: wsUri,
         editor,
         onConnected: () => setConnection("connected"),
@@ -68,11 +69,40 @@ function App() {
             duration: null,
           });
         },
+        onChangeLanguage: (language) => {
+          if (languages.includes(language)) {
+            setLanguage(language);
+          }
+        },
       });
-      return () => rustpad.dispose();
+      return () => {
+        rustpad.current?.dispose();
+        rustpad.current = undefined;
+      };
     }
   }, [editor, toast]);
 
+  function handleChangeLanguage(language: string) {
+    setLanguage(language);
+    if (rustpad.current?.setLanguage(language)) {
+      toast({
+        title: "Language updated",
+        description: (
+          <>
+            All users are now editing in{" "}
+            <Text as="span" fontWeight="semibold">
+              {language}
+            </Text>
+            .
+          </>
+        ),
+        status: "info",
+        duration: 2000,
+        isClosable: true,
+      });
+    }
+  }
+
   async function handleCopy() {
     await navigator.clipboard.writeText(`${window.location.origin}/#${id}`);
     toast({
@@ -128,7 +158,7 @@ function App() {
               size="sm"
               bgColor="white"
               value={language}
-              onChange={(event) => setLanguage(event.target.value)}
+              onChange={(event) => handleChangeLanguage(event.target.value)}
             >
               {languages.map((lang) => (
                 <option key={lang} value={lang}>

+ 10 - 0
src/rustpad.ts

@@ -8,6 +8,7 @@ export type RustpadOptions = {
   readonly onConnected?: () => unknown;
   readonly onDisconnected?: () => unknown;
   readonly onDesynchronized?: () => unknown;
+  readonly onChangeLanguage?: (language: string) => unknown;
   readonly reconnectInterval?: number;
 };
 
@@ -65,6 +66,12 @@ class Rustpad {
     this.ws?.close();
   }
 
+  /** Try to set the language of the editor, if connected. */
+  setLanguage(language: string): boolean {
+    this.ws?.send(`{"SetLanguage":${JSON.stringify(language)}}`);
+    return this.ws !== undefined;
+  }
+
   /**
    * Attempts a WebSocket connection.
    *
@@ -129,6 +136,8 @@ class Rustpad {
           this.applyServer(operation);
         }
       }
+    } else if (msg.Language !== undefined) {
+      this.options.onChangeLanguage?.(msg.Language);
     }
   }
 
@@ -271,6 +280,7 @@ type ServerMsg = {
     start: number;
     operations: UserOperation[];
   };
+  Language?: string;
 };
 
 export default Rustpad;