vzcode 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/assets/index-5f9f376e.css +1 -0
  2. package/dist/assets/index-70b6d947.js +111 -0
  3. package/dist/index.html +2 -6
  4. package/package.json +7 -7
  5. package/src/client/App.tsx +110 -247
  6. package/src/client/CodeEditor/getOrCreateEditor.ts +6 -1
  7. package/src/client/CodeEditor/index.tsx +94 -16
  8. package/src/client/CodeEditor/{json1PresenceBroadcast.js → json1PresenceBroadcast.ts} +9 -3
  9. package/src/client/CodeEditor/{json1PresenceDisplay.js → json1PresenceDisplay.ts} +9 -48
  10. package/src/client/CodeEditor/style.scss +5 -1
  11. package/src/client/CodeErrorOverlay/index.tsx +27 -0
  12. package/src/client/CodeErrorOverlay/style.scss +30 -0
  13. package/src/client/PresenceNotifications/PresenceNotifications.test.ts +134 -0
  14. package/src/client/PresenceNotifications/index.tsx +119 -106
  15. package/src/client/SplitPaneResizeContext.tsx +12 -7
  16. package/src/client/TabList/Tab.tsx +70 -0
  17. package/src/client/TabList/index.tsx +28 -69
  18. package/src/client/TabList/style.scss +26 -9
  19. package/src/client/{Settings.tsx → VZSettings.tsx} +34 -21
  20. package/src/client/{Sidebar/ConfirmationBox.tsx → VZSidebar/DeleteConfirmationModal.tsx} +17 -14
  21. package/src/client/{Sidebar → VZSidebar}/DirectoryListing.tsx +27 -26
  22. package/src/client/{Sidebar → VZSidebar}/FileListing.tsx +19 -19
  23. package/src/client/VZSidebar/Item.tsx +179 -0
  24. package/src/client/{Sidebar → VZSidebar}/Listing.tsx +20 -14
  25. package/src/client/{Sidebar → VZSidebar}/index.tsx +48 -30
  26. package/src/client/{Sidebar → VZSidebar}/styles.scss +17 -12
  27. package/src/client/bootstrap.ts +3 -0
  28. package/src/client/style.scss +8 -2
  29. package/src/client/useActions.ts +93 -0
  30. package/src/client/useEditorCache.ts +2 -1
  31. package/src/client/useFileCRUD.ts +103 -0
  32. package/src/client/useOpenDirectories.ts +25 -7
  33. package/src/client/useSubmitOperation.ts +25 -0
  34. package/src/client/usernameLocalStorage.ts +43 -0
  35. package/src/client/vzReducer/closeTabReducer.test.ts +177 -0
  36. package/src/client/vzReducer/closeTabsReducer.ts +68 -0
  37. package/src/client/vzReducer/createInitialState.ts +19 -0
  38. package/src/client/vzReducer/editorNoLongerWantsFocusReducer.ts +9 -0
  39. package/src/client/vzReducer/index.ts +107 -0
  40. package/src/client/vzReducer/openTabReducer.test.ts +110 -0
  41. package/src/client/vzReducer/openTabReducer.ts +57 -0
  42. package/src/client/vzReducer/setActiveFileIdReducer.ts +9 -0
  43. package/src/client/vzReducer/setIsSettingsOpenReducer.ts +9 -0
  44. package/src/client/vzReducer/setThemeReducer.ts +9 -0
  45. package/src/client/vzReducer/setUsernameReducer.ts +9 -0
  46. package/src/index.ts +40 -0
  47. package/src/server/index.js +13 -2
  48. package/src/types.ts +12 -5
  49. package/dist/assets/index-8628ebab.js +0 -111
  50. package/dist/assets/index-cf19de3b.css +0 -1
  51. package/src/client/PrettierErrorOverlay/index.tsx +0 -9
  52. package/src/client/PrettierErrorOverlay/style.scss +0 -9
  53. package/src/client/Sidebar/Item.tsx +0 -130
  54. package/src/client/reducer.ts +0 -119
  55. /package/src/client/{Sidebar → VZSidebar}/DirectoryArrowSVG.jsx +0 -0
@@ -1,7 +1,13 @@
1
- import { useRef, useLayoutEffect, useContext } from 'react';
1
+ import {
2
+ useRef,
3
+ useLayoutEffect,
4
+ useCallback,
5
+ useMemo,
6
+ } from 'react';
2
7
  import {
3
8
  FileId,
4
9
  ShareDBDoc,
10
+ Username,
5
11
  VZCodeContent,
6
12
  } from '../../types';
7
13
  import { ThemeLabel, defaultTheme } from '../themes';
@@ -11,20 +17,25 @@ import {
11
17
  } from '../useEditorCache';
12
18
  import { getOrCreateEditor } from './getOrCreateEditor';
13
19
  import './style.scss';
14
- // import { usePrettier } from '../usePrettier';
15
20
 
16
21
  export const CodeEditor = ({
17
22
  activeFileId,
18
23
  shareDBDoc,
24
+ submitOperation,
19
25
  localPresence,
20
26
  docPresence,
21
27
  theme = defaultTheme,
22
28
  filesPath = ['files'],
23
- onInteract,
24
29
  editorCache,
30
+ editorWantsFocus,
31
+ editorNoLongerWantsFocus,
32
+ username,
25
33
  }: {
26
34
  activeFileId: FileId;
27
35
  shareDBDoc: ShareDBDoc<VZCodeContent> | null;
36
+ submitOperation: (
37
+ next: (content: VZCodeContent) => VZCodeContent,
38
+ ) => void;
28
39
  localPresence?: any;
29
40
  docPresence?: any;
30
41
  theme?: ThemeLabel;
@@ -32,22 +43,51 @@ export const CodeEditor = ({
32
43
  // The path of the files object in the ShareDB document.
33
44
  // Defaults to `files` if not provided.
34
45
  filesPath?: string[];
35
- onInteract?: () => void;
36
46
  editorCache: EditorCache;
47
+
48
+ // Whether the editor should be focused.
49
+ editorWantsFocus: boolean;
50
+
51
+ // Signals that the editor no longer wants focus.
52
+ editorNoLongerWantsFocus: () => void;
53
+ username: Username;
37
54
  }) => {
38
55
  const ref = useRef<HTMLDivElement>(null);
39
- // Every time the active file switches from one file to another,
40
- // the editor corresponding to the old file is removed from the DOM,
41
- // and the editor corresponding to the new file is added to the DOM.
42
56
 
43
- useLayoutEffect(() => {
44
- // Guard against cases where page is still loading.
45
- if (!ref.current) return;
46
- if (!shareDBDoc) return;
57
+ // Set `doc.data.isInteracting` to `true` when the user is interacting
58
+ // via interactive code widgets (e.g. Alt+drag), and `false` when they are not.
59
+ const interactTimeoutRef = useRef(null);
60
+
61
+ const onInteract = useCallback(() => {
62
+ // Set `isInteracting: true` if not already set.
63
+ if (!interactTimeoutRef.current) {
64
+ submitOperation((document) => ({
65
+ ...document,
66
+ isInteracting: true,
67
+ }));
68
+ } else {
69
+ clearTimeout(interactTimeoutRef.current);
70
+ }
71
+
72
+ // Set `isInteracting: false` after a delay.
73
+ interactTimeoutRef.current = setTimeout(() => {
74
+ interactTimeoutRef.current = null;
75
+ submitOperation((document) => ({
76
+ ...document,
77
+ isInteracting: false,
78
+ }));
79
+ }, 800);
80
+ }, [submitOperation]);
47
81
 
48
- // Get the editor corresponding to the active file.
49
- // Looks in `editorCache` first, and if not found, creates a new editor.
50
- const editorCacheValue: EditorCacheValue =
82
+ // Track username on a ref, so that we can use it in the
83
+ // presence object's `username` property.
84
+ const usernameRef = useRef<Username>(username);
85
+ usernameRef.current = username;
86
+
87
+ // Get the editor corresponding to the active file.
88
+ // Looks in `editorCache` first, and if not found, creates a new editor.
89
+ const editorCacheValue: EditorCacheValue = useMemo(
90
+ () =>
51
91
  getOrCreateEditor({
52
92
  fileId: activeFileId,
53
93
  shareDBDoc,
@@ -57,7 +97,28 @@ export const CodeEditor = ({
57
97
  theme,
58
98
  onInteract,
59
99
  editorCache,
60
- });
100
+ usernameRef,
101
+ }),
102
+ [
103
+ activeFileId,
104
+ shareDBDoc,
105
+ filesPath,
106
+ localPresence,
107
+ docPresence,
108
+ theme,
109
+ onInteract,
110
+ editorCache,
111
+ usernameRef,
112
+ ],
113
+ );
114
+
115
+ // Every time the active file switches from one file to another,
116
+ // the editor corresponding to the old file is removed from the DOM,
117
+ // and the editor corresponding to the new file is added to the DOM.
118
+ useLayoutEffect(() => {
119
+ // Guard against cases where page is still loading.
120
+ if (!ref.current) return;
121
+ if (!shareDBDoc) return;
61
122
 
62
123
  // Add the editor to the DOM.
63
124
  ref.current.appendChild(editorCacheValue.editor.dom);
@@ -67,7 +128,24 @@ export const CodeEditor = ({
67
128
  // This happens every time `activeFileId` changes.
68
129
  ref.current.removeChild(editorCacheValue.editor.dom);
69
130
  };
70
- }, [shareDBDoc, activeFileId]);
131
+ }, [shareDBDoc, editorCacheValue]);
132
+
133
+ // useEffect(() => {
134
+ // if (editorWantsFocus) {
135
+ // editorCacheValue.editor.focus();
136
+ // }
137
+
138
+ // Focus the editor
139
+ useLayoutEffect(() => {
140
+ if (editorWantsFocus) {
141
+ editorCacheValue.editor.focus();
142
+ editorNoLongerWantsFocus();
143
+ }
144
+ }, [
145
+ editorWantsFocus,
146
+ editorCacheValue,
147
+ editorNoLongerWantsFocus,
148
+ ]);
71
149
 
72
150
  return <div className="vz-code-editor" ref={ref}></div>;
73
151
  };
@@ -1,15 +1,17 @@
1
+ import { ViewUpdate } from '@codemirror/view';
1
2
  import { EditorView } from 'codemirror';
2
- import { username } from '../Settings.tsx';
3
3
 
4
4
  // Deals with broadcasting changes in cursor location and selection.
5
5
  export const json1PresenceBroadcast = ({
6
6
  path,
7
7
  localPresence,
8
+ usernameRef,
8
9
  }) =>
9
10
  // See https://discuss.codemirror.net/t/codemirror-6-proper-way-to-listen-for-changes/2395/10
10
- EditorView.updateListener.of((viewUpdate) => {
11
+ EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
11
12
  // If this update modified the cursor / selection,
12
13
  // we broadcast the selection update via ShareDB presence.
14
+
13
15
  if (viewUpdate.selectionSet) {
14
16
  // Isolate the single selection to use for presence.
15
17
  // Unfortunately JSON1 with presence does not yet
@@ -23,7 +25,11 @@ export const json1PresenceBroadcast = ({
23
25
  const presence = {
24
26
  start: [...path, from],
25
27
  end: [...path, to],
26
- username: username,
28
+
29
+ // We use a ref here so that the presence always broadcasts
30
+ // the latest username, in the case that it has been changed
31
+ // in the Settings mid-session.
32
+ username: usernameRef.current,
27
33
  };
28
34
 
29
35
  // Broadcast presence to remote clients!
@@ -20,7 +20,12 @@ export const json1PresenceDisplay = ({
20
20
  }) => [
21
21
  ViewPlugin.fromClass(
22
22
  class {
23
- constructor(view) {
23
+ // The decorations to display.
24
+ // This is a RangeSet of Decoration objects.
25
+ // See https://codemirror.net/6/docs/ref/#view.Decoration
26
+ decorations: RangeSet<Decoration>;
27
+
28
+ constructor(view: EditorView) {
24
29
  // Initialize decorations to empty array so CodeMirror doesn't crash.
25
30
  this.decorations = RangeSet.of([]);
26
31
 
@@ -32,10 +37,6 @@ export const json1PresenceDisplay = ({
32
37
  // Receive remote presence changes.
33
38
  docPresence.on('receive', (id, presence) => {
34
39
  // If presence === null, the user has disconnected / exited
35
- // notification(presence);
36
- // console.log('docPresence in presenceDisplay:',docPresence);
37
- // console.log('presence in presenceDisplay:', presence);
38
-
39
40
  // We also check if the presence is for the current file or not.
40
41
  if (presence && pathMatches(path, presence)) {
41
42
  presenceState[id] = presence;
@@ -102,47 +103,6 @@ export const json1PresenceDisplay = ({
102
103
  presenceTheme,
103
104
  ];
104
105
 
105
- // const notification = (presence) => {
106
- // const notif = document.createElement('div');
107
- // notif.className = 'notification-presence';
108
- // if (presence === null){
109
- // notif.textContent = 'User asdfgasdfgasdfg left the collaboration session.';
110
- // notif.style.display = 'block';
111
- // }
112
- // // The following causes the popup to show every time the remote cursor moves
113
- // // to a different location on the file and not when the user first joins
114
- // // else {
115
- // // notif.textContent = presence + 'joined the collaboration session.';
116
- // // notif.style.display = 'block';
117
- // // }
118
- // document.body.appendChild(notif);
119
-
120
- // const notifStyle = document.createElement('style');
121
- // notifStyle.innerText = `
122
- // .notification-presence {
123
- // position: fixed;
124
- // display: none;
125
- // bottom: 20px;
126
- // right: 20px;
127
- // max-width: 300px;
128
- // overflow-x: hidden;
129
- // white-space: nowrap;
130
- // background-color: rgb(54, 65, 89);
131
- // color: rgb(154, 160, 172);
132
- // padding: 15px;
133
- // }
134
- // .notification-presence:hover {
135
- // overflow-x: auto;
136
- // }`;
137
- // document.head.appendChild(notifStyle);
138
-
139
- // setTimeout(() => {
140
- // notif.style.display = 'none';
141
- // }, 10000);
142
-
143
- // return notif;
144
- // };
145
-
146
106
  const presenceAnnotation = Annotation.define();
147
107
 
148
108
  // Checks that the path of this file
@@ -161,12 +121,13 @@ const pathMatches = (path, presence) => {
161
121
 
162
122
  // Displays a single remote presence cursor.
163
123
  class PresenceWidget extends WidgetType {
164
- constructor(id) {
124
+ id: string;
125
+ constructor(id: string) {
165
126
  super();
166
127
  this.id = id;
167
128
  }
168
129
 
169
- eq(other) {
130
+ eq(other: PresenceWidget) {
170
131
  return other.id === this.id;
171
132
  }
172
133
 
@@ -4,8 +4,12 @@
4
4
  font-family: var(--vzcode-font-family);
5
5
  overflow-x: auto;
6
6
 
7
- /* TODO QA this */
8
7
  .cm-editor {
9
8
  flex: 1;
10
9
  }
10
+
11
+ .cm-scroller {
12
+ font-family: var(--vzcode-font-family);
13
+ font-size: var(--vzcode-font-size);
14
+ }
11
15
  }
@@ -0,0 +1,27 @@
1
+ import { useCallback } from 'react';
2
+ import './style.scss';
3
+
4
+ // Feature flag for the button that dismisses the error.
5
+ const enableErrorDismiss = false;
6
+
7
+ export const CodeErrorOverlay = ({
8
+ errorMessage,
9
+ }: {
10
+ errorMessage: string | null;
11
+ }) => {
12
+ const handleCloseClick = useCallback(() => {
13
+ console.log('TODO dismiss the error');
14
+ }, []);
15
+
16
+ return errorMessage !== null ? (
17
+ <pre className="vz-code-error-overlay">
18
+ {errorMessage}
19
+ {enableErrorDismiss ? (
20
+ <div
21
+ className={'bx bx-x error-dismiss-button'}
22
+ onClick={handleCloseClick}
23
+ ></div>
24
+ ) : null}
25
+ </pre>
26
+ ) : null;
27
+ };
@@ -0,0 +1,30 @@
1
+ .vz-code-error-overlay {
2
+ color: rgb(255, 35, 35);
3
+ font-family: var(--vzcode-font-family);
4
+ font-size: var(--vzcode-font-size);
5
+ background-color: rgba(0, 0, 0, 0.6);
6
+ position: absolute;
7
+ bottom: 0;
8
+ left: 0;
9
+ right: 0;
10
+ // pointer-events: none;
11
+ margin: 0;
12
+ padding: 8px;
13
+
14
+ border-top-left-radius: 12px;
15
+ border-top-right-radius: 12px;
16
+
17
+ // TODO unify with styles in tab close button
18
+ .error-dismiss-button {
19
+ position: absolute;
20
+ top: 0;
21
+ right: 0;
22
+ margin: 5px;
23
+ cursor: pointer;
24
+ color: white;
25
+ }
26
+ .error-dismiss-button:hover {
27
+ border-radius: 50%;
28
+ background-color: rgba(255, 255, 255, 0.2);
29
+ }
30
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ extractTimestampFromId,
4
+ shouldShowNotification,
5
+ handleJoin,
6
+ handleLeave,
7
+ } from '.';
8
+
9
+ describe('extractTimestampFromId', () => {
10
+ it('Extracts a timestamp from a given ID', () => {
11
+ const id = 'abcd-xyz';
12
+ const timestamp = extractTimestampFromId(id);
13
+ expect(timestamp).toBe(parseInt('abcd', 36));
14
+ });
15
+ });
16
+
17
+ describe('shouldShowNotification', () => {
18
+ it('Returns true if remote presence timestamp is greater than local', () => {
19
+ const remoteId = 'abcd-xyz';
20
+ const localId = 'aaa-xyz';
21
+ const result = shouldShowNotification(
22
+ remoteId,
23
+ localId,
24
+ );
25
+ expect(result).toBe(true);
26
+ });
27
+
28
+ it('Returns false if remote presence timestamp is not greater than local', () => {
29
+ const remoteId = 'aaa-xyz';
30
+ const localId = 'abcd-xyz';
31
+ const result = shouldShowNotification(
32
+ remoteId,
33
+ localId,
34
+ );
35
+ expect(result).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe('handleJoin', () => {
40
+ it('Adds a new user if not already present', () => {
41
+ const presenceId = 'abcd-xyz';
42
+ const username = 'John';
43
+ const alreadyJoined = { current: new Map() };
44
+ let notifications = [];
45
+ const setNotifications = (updater) => {
46
+ notifications = updater(notifications);
47
+ };
48
+
49
+ handleJoin(
50
+ presenceId,
51
+ username,
52
+ alreadyJoined,
53
+ setNotifications,
54
+ );
55
+
56
+ expect(notifications.length).toBe(1);
57
+ expect(notifications[0].user).toBe(username);
58
+ expect(notifications[0].join).toBe(true);
59
+ expect(alreadyJoined.current.has(presenceId)).toBe(
60
+ true,
61
+ );
62
+ });
63
+
64
+ it('Updates username if user already present', () => {
65
+ const presenceId = 'abcd-xyz';
66
+ const username = 'John';
67
+ const alreadyJoined = {
68
+ current: new Map([[presenceId, 'Doe']]),
69
+ };
70
+ let notifications = [];
71
+ const setNotifications = (updater) => {
72
+ notifications = updater(notifications);
73
+ };
74
+
75
+ handleJoin(
76
+ presenceId,
77
+ username,
78
+ alreadyJoined,
79
+ setNotifications,
80
+ );
81
+
82
+ expect(notifications.length).toBe(0);
83
+ expect(alreadyJoined.current.get(presenceId)).toBe(
84
+ username,
85
+ );
86
+ });
87
+ });
88
+
89
+ describe('handleLeave', () => {
90
+ it('Removes an existing user', () => {
91
+ const presenceId = 'abcd-xyz';
92
+ const username = 'John';
93
+ const alreadyJoined = {
94
+ current: new Map([[presenceId, username]]),
95
+ };
96
+ let notifications = [];
97
+ const setNotifications = (updater) => {
98
+ notifications = updater(notifications);
99
+ };
100
+
101
+ handleLeave(
102
+ presenceId,
103
+ alreadyJoined,
104
+ setNotifications,
105
+ );
106
+
107
+ expect(notifications.length).toBe(1);
108
+ expect(notifications[0].user).toBe(username);
109
+ expect(notifications[0].join).toBe(false);
110
+ expect(alreadyJoined.current.has(presenceId)).toBe(
111
+ false,
112
+ );
113
+ });
114
+
115
+ it('Does nothing if user not present', () => {
116
+ const presenceId = 'abcd-xyz';
117
+ const alreadyJoined = { current: new Map() };
118
+ let notifications = [];
119
+ const setNotifications = (updater) => {
120
+ notifications = updater(notifications);
121
+ };
122
+
123
+ handleLeave(
124
+ presenceId,
125
+ alreadyJoined,
126
+ setNotifications,
127
+ );
128
+
129
+ expect(notifications.length).toBe(0);
130
+ expect(alreadyJoined.current.has(presenceId)).toBe(
131
+ false,
132
+ );
133
+ });
134
+ });