darabos commited on
Commit
ca97c6b
·
1 Parent(s): 544e2ab

Pass code edits through CRDT.

Browse files
examples/word2vec.py CHANGED
@@ -22,3 +22,8 @@ def word2vec_1000():
22
  @op(ENV, "Take first N")
23
  def first_n(df: pd.DataFrame, *, n=10):
24
  return df.head(n)
 
 
 
 
 
 
22
  @op(ENV, "Take first N")
23
  def first_n(df: pd.DataFrame, *, n=10):
24
  return df.head(n)
25
+
26
+
27
+ @op(ENV, "Sample N")
28
+ def sample_n(df: pd.DataFrame, *, n=10):
29
+ return df.sample(n)
lynxkite-app/src/lynxkite_app/crdt.py CHANGED
@@ -26,11 +26,11 @@ def ws_exception_handler(exception, log):
26
  return True
27
 
28
 
29
- class WebsocketServer(pycrdt_websocket.WebsocketServer):
30
  async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
31
  """Initialize a room for the workspace with the given name.
32
 
33
- The workspace is loaded from "crdt_data" if it exists there, or from "data", or a new workspace is created.
34
  """
35
  crdt_path = pathlib.Path(".crdt")
36
  path = crdt_path / f"{name}.crdt"
@@ -82,6 +82,37 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
82
  return room
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  last_ws_input = None
86
 
87
 
@@ -247,14 +278,21 @@ async def execute(name: str, ws_crdt: pycrdt.Map, ws_pyd: workspace.Workspace, d
247
  print(f"Finished running {name} in {ws_pyd.env}.")
248
 
249
 
 
 
 
 
 
 
250
  @contextlib.asynccontextmanager
251
  async def lifespan(app):
252
- global websocket_server
253
- websocket_server = WebsocketServer(
254
- auto_clean_rooms=False,
255
- )
256
- async with websocket_server:
257
- yield
 
258
  print("closing websocket server")
259
 
260
 
@@ -265,5 +303,12 @@ def sanitize_path(path):
265
  @router.websocket("/ws/crdt/{room_name}")
266
  async def crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
267
  room_name = sanitize_path(room_name)
268
- server = pycrdt_websocket.ASGIServer(websocket_server)
 
 
 
 
 
 
 
269
  await server({"path": room_name}, websocket._receive, websocket._send)
 
26
  return True
27
 
28
 
29
+ class WorkspaceWebsocketServer(pycrdt_websocket.WebsocketServer):
30
  async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
31
  """Initialize a room for the workspace with the given name.
32
 
33
+ The workspace is loaded from ".crdt" if it exists there, or from a JSON file, or a new workspace is created.
34
  """
35
  crdt_path = pathlib.Path(".crdt")
36
  path = crdt_path / f"{name}.crdt"
 
82
  return room
83
 
84
 
85
+ class CodeWebsocketServer(WorkspaceWebsocketServer):
86
+ async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
87
+ """Initialize a room for a text document with the given name."""
88
+ crdt_path = pathlib.Path(".crdt")
89
+ path = crdt_path / f"{name}.crdt"
90
+ assert path.is_relative_to(crdt_path)
91
+ ystore = pycrdt_websocket.ystore.FileYStore(path)
92
+ ydoc = pycrdt.Doc()
93
+ ydoc["text"] = text = pycrdt.Text()
94
+ # Replay updates from the store.
95
+ try:
96
+ for update, timestamp in [(item[0], item[-1]) async for item in ystore.read()]:
97
+ ydoc.apply_update(update)
98
+ except pycrdt_websocket.ystore.YDocNotFound:
99
+ pass
100
+ if len(text) == 0:
101
+ if os.path.exists(name):
102
+ with open(name) as f:
103
+ text += f.read()
104
+ room = pycrdt_websocket.YRoom(
105
+ ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
106
+ )
107
+ room.text = text
108
+
109
+ def on_change(changes):
110
+ asyncio.create_task(code_changed(name, changes, text))
111
+
112
+ text.observe(on_change)
113
+ return room
114
+
115
+
116
  last_ws_input = None
117
 
118
 
 
278
  print(f"Finished running {name} in {ws_pyd.env}.")
279
 
280
 
281
+ async def code_changed(name: str, changes: pycrdt.TextEvent, text: pycrdt.Text):
282
+ # TODO: Make this more fancy?
283
+ with open(name, "w") as f:
284
+ f.write(str(text))
285
+
286
+
287
  @contextlib.asynccontextmanager
288
  async def lifespan(app):
289
+ global ws_websocket_server
290
+ global code_websocket_server
291
+ ws_websocket_server = WorkspaceWebsocketServer(auto_clean_rooms=False)
292
+ code_websocket_server = CodeWebsocketServer(auto_clean_rooms=False)
293
+ async with ws_websocket_server:
294
+ async with code_websocket_server:
295
+ yield
296
  print("closing websocket server")
297
 
298
 
 
303
  @router.websocket("/ws/crdt/{room_name}")
304
  async def crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
305
  room_name = sanitize_path(room_name)
306
+ server = pycrdt_websocket.ASGIServer(ws_websocket_server)
307
+ await server({"path": room_name}, websocket._receive, websocket._send)
308
+
309
+
310
+ @router.websocket("/ws/code/crdt/{room_name}")
311
+ async def code_crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
312
+ room_name = sanitize_path(room_name)
313
+ server = pycrdt_websocket.ASGIServer(code_websocket_server)
314
  await server({"path": room_name}, websocket._receive, websocket._send)
lynxkite-app/src/lynxkite_app/main.py CHANGED
@@ -39,15 +39,6 @@ def get_catalog(workspace: str):
39
  return {k: {op.name: op.model_dump() for op in v.values()} for k, v in ops.CATALOGS.items()}
40
 
41
 
42
- @app.get("/api/getCode")
43
- def get_code(path: str):
44
- path = data_path / path
45
- assert path.is_relative_to(data_path)
46
- with open(path) as f:
47
- code = f.read()
48
- return {"code": code}
49
-
50
-
51
  class SaveRequest(workspace.BaseConfig):
52
  path: str
53
  ws: workspace.Workspace
 
39
  return {k: {op.name: op.model_dump() for op in v.values()} for k, v in ops.CATALOGS.items()}
40
 
41
 
 
 
 
 
 
 
 
 
 
42
  class SaveRequest(workspace.BaseConfig):
43
  path: str
44
  ws: workspace.Workspace
lynxkite-app/web/package-lock.json CHANGED
@@ -29,6 +29,7 @@
29
  "react-router-dom": "^7.0.2",
30
  "swr": "^2.2.5",
31
  "unplugin-icons": "^0.21.0",
 
32
  "y-websocket": "^2.0.4",
33
  "yjs": "^13.6.20"
34
  },
@@ -7397,6 +7398,23 @@
7397
  "yjs": "^13.0.0"
7398
  }
7399
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7400
  "node_modules/y-protocols": {
7401
  "version": "1.0.6",
7402
  "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
 
29
  "react-router-dom": "^7.0.2",
30
  "swr": "^2.2.5",
31
  "unplugin-icons": "^0.21.0",
32
+ "y-monaco": "^0.1.6",
33
  "y-websocket": "^2.0.4",
34
  "yjs": "^13.6.20"
35
  },
 
7398
  "yjs": "^13.0.0"
7399
  }
7400
  },
7401
+ "node_modules/y-monaco": {
7402
+ "version": "0.1.6",
7403
+ "resolved": "https://registry.npmjs.org/y-monaco/-/y-monaco-0.1.6.tgz",
7404
+ "integrity": "sha512-sYRywMmcylt+Nupl+11AvizD2am06ST8lkVbUXuaEmrtV6Tf+TD4rsEm6u9YGGowYue+Vfg1IJ97SUP2J+PVXg==",
7405
+ "license": "MIT",
7406
+ "dependencies": {
7407
+ "lib0": "^0.2.43"
7408
+ },
7409
+ "engines": {
7410
+ "node": ">=12.0.0",
7411
+ "npm": ">=6.0.0"
7412
+ },
7413
+ "peerDependencies": {
7414
+ "monaco-editor": ">=0.20.0",
7415
+ "yjs": "^13.3.1"
7416
+ }
7417
+ },
7418
  "node_modules/y-protocols": {
7419
  "version": "1.0.6",
7420
  "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
lynxkite-app/web/package.json CHANGED
@@ -32,6 +32,7 @@
32
  "react-router-dom": "^7.0.2",
33
  "swr": "^2.2.5",
34
  "unplugin-icons": "^0.21.0",
 
35
  "y-websocket": "^2.0.4",
36
  "yjs": "^13.6.20"
37
  },
 
32
  "react-router-dom": "^7.0.2",
33
  "swr": "^2.2.5",
34
  "unplugin-icons": "^0.21.0",
35
+ "y-monaco": "^0.1.6",
36
  "y-websocket": "^2.0.4",
37
  "yjs": "^13.6.20"
38
  },
lynxkite-app/web/src/Code.tsx CHANGED
@@ -1,10 +1,11 @@
1
  // Full-page editor for code files.
2
 
3
- import Editor from "@monaco-editor/react";
4
- import { loader } from "@monaco-editor/react";
5
- import { useEffect } from "react";
6
  import { useParams } from "react-router";
7
- import useSWR, { type Fetcher } from "swr";
 
8
  // @ts-ignore
9
  import Atom from "~icons/tabler/atom.jsx";
10
  // @ts-ignore
@@ -13,20 +14,41 @@ import Backspace from "~icons/tabler/backspace.jsx";
13
  import Close from "~icons/tabler/x.jsx";
14
  import favicon from "./assets/favicon.ico";
15
  import theme from "./code-theme.ts";
 
 
16
 
17
  export default function Code() {
18
- useEffect(() => {
19
- const initMonaco = async () => {
20
- const monaco = await loader.init();
21
- monaco.editor.defineTheme("lynxkite", theme);
22
- };
23
- initMonaco();
24
- }, []);
25
  const { path } = useParams();
26
  const parentDir = path!.split("/").slice(0, -1).join("/");
27
- const fetcher: Fetcher<{ code: string }> = (resource: string, init?: RequestInit) =>
28
- fetch(resource, init).then((res) => res.json());
29
- const code = useSWR(`/api/getCode?path=${path}`, fetcher);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  return (
31
  <div className="workspace">
32
  <div className="top-bar bg-neutral">
@@ -46,29 +68,19 @@ export default function Code() {
46
  </a>
47
  </div>
48
  </div>
49
- {code.isLoading && (
50
- <div className="loading">
51
- <div className="loading-text">Loading...</div>
52
- </div>
53
- )}
54
- {code.error && (
55
- <div className="error">
56
- <div className="error-text">Error: {code.error}</div>
57
- </div>
58
- )}
59
- {code.data && (
60
- <Editor
61
- defaultLanguage="python"
62
- defaultValue={code.data.code}
63
- theme="lynxkite"
64
- options={{
65
- cursorStyle: "block",
66
- cursorBlinking: "solid",
67
- minimap: { enabled: false },
68
- renderLineHighlight: "none",
69
- }}
70
- />
71
- )}
72
  </div>
73
  );
74
  }
 
1
  // Full-page editor for code files.
2
 
3
+ import Editor, { type Monaco } from "@monaco-editor/react";
4
+ import type { editor } from "monaco-editor";
5
+ import { useEffect, useRef } from "react";
6
  import { useParams } from "react-router";
7
+ import { WebsocketProvider } from "y-websocket";
8
+ import * as Y from "yjs";
9
  // @ts-ignore
10
  import Atom from "~icons/tabler/atom.jsx";
11
  // @ts-ignore
 
14
  import Close from "~icons/tabler/x.jsx";
15
  import favicon from "./assets/favicon.ico";
16
  import theme from "./code-theme.ts";
17
+ // For some reason y-monaco is gigantic. The other Monaco packages are small.
18
+ const MonacoBinding = await import("y-monaco").then((m) => m.MonacoBinding);
19
 
20
  export default function Code() {
 
 
 
 
 
 
 
21
  const { path } = useParams();
22
  const parentDir = path!.split("/").slice(0, -1).join("/");
23
+ const ydoc = useRef<any>();
24
+ const wsProvider = useRef<any>();
25
+ const monacoBinding = useRef<any>();
26
+ function beforeMount(monaco: Monaco) {
27
+ monaco.editor.defineTheme("lynxkite", theme);
28
+ }
29
+ function onMount(_editor: editor.IStandaloneCodeEditor) {
30
+ ydoc.current = new Y.Doc();
31
+ const text = ydoc.current.getText("text");
32
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
33
+ wsProvider.current = new WebsocketProvider(
34
+ `${proto}//${location.host}/ws/code/crdt`,
35
+ path!,
36
+ ydoc.current,
37
+ );
38
+ monacoBinding.current = new MonacoBinding(
39
+ text,
40
+ _editor.getModel()!,
41
+ new Set([_editor]),
42
+ wsProvider.current.awareness,
43
+ );
44
+ }
45
+ useEffect(() => {
46
+ return () => {
47
+ ydoc.current?.destroy();
48
+ wsProvider.current?.destroy();
49
+ monacoBinding.current?.destroy();
50
+ };
51
+ });
52
  return (
53
  <div className="workspace">
54
  <div className="top-bar bg-neutral">
 
68
  </a>
69
  </div>
70
  </div>
71
+ <Editor
72
+ defaultLanguage="python"
73
+ theme="lynxkite"
74
+ path={path}
75
+ beforeMount={beforeMount}
76
+ onMount={onMount}
77
+ options={{
78
+ cursorStyle: "block",
79
+ cursorBlinking: "solid",
80
+ minimap: { enabled: false },
81
+ renderLineHighlight: "none",
82
+ }}
83
+ />
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
  );
86
  }