vzcode 0.51.0 → 0.52.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.
package/dist/index.html CHANGED
@@ -24,8 +24,8 @@
24
24
  href="https://unpkg.com/boxicons@2.0.7/css/boxicons.min.css"
25
25
  rel="stylesheet"
26
26
  />
27
- <script type="module" crossorigin src="/assets/index-yk4vJ5U-.js"></script>
28
- <link rel="stylesheet" crossorigin href="/assets/index-jYtAJn79.css">
27
+ <script type="module" crossorigin src="/assets/index-zjaIucrc.js"></script>
28
+ <link rel="stylesheet" crossorigin href="/assets/index-7PozlhVS.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vzcode",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -63,12 +63,12 @@
63
63
  },
64
64
  "homepage": "https://github.com/vizhub-core/vzcode#readme",
65
65
  "dependencies": {
66
- "@codemirror/autocomplete": "^6.11.1",
66
+ "@codemirror/autocomplete": "^6.12.0",
67
67
  "@codemirror/lang-css": "^6.2.1",
68
68
  "@codemirror/lang-html": "^6.4.7",
69
69
  "@codemirror/lang-javascript": "^6.2.1",
70
70
  "@codemirror/lang-json": "^6.0.1",
71
- "@codemirror/lang-markdown": "^6.2.3",
71
+ "@codemirror/lang-markdown": "^6.2.4",
72
72
  "@codemirror/state": "^6.4.0",
73
73
  "@codemirror/theme-one-dark": "^6.1.2",
74
74
  "@codemirror/view": "^6.23.0",
@@ -99,24 +99,24 @@
99
99
  "json0-ot-diff": "^1.1.2",
100
100
  "ngrok": "^4.3.3",
101
101
  "open": "^10.0.3",
102
- "openai": "^4.24.3",
102
+ "openai": "^4.24.7",
103
103
  "react": "^18.2.0",
104
- "react-bootstrap": "^2.9.2",
104
+ "react-bootstrap": "^2.10.0",
105
105
  "react-dom": "^18.2.0",
106
106
  "react-router-dom": "^6.21.2",
107
107
  "sharedb": "^4.1.2",
108
108
  "sharedb-client-browser": "^4.4.0",
109
- "vizhub-ui": "^3.17.0",
109
+ "vizhub-ui": "^3.18.0",
110
110
  "ws": "^8.16.0"
111
111
  },
112
112
  "devDependencies": {
113
- "@types/react": "^18.2.47",
113
+ "@types/react": "^18.2.48",
114
114
  "@types/react-dom": "^18.2.18",
115
115
  "@vitejs/plugin-react": "^4.2.1",
116
- "prettier": "^3.1.1",
117
- "sass": "^1.69.7",
116
+ "prettier": "^3.2.4",
117
+ "sass": "^1.70.0",
118
118
  "typescript": "^5.3.3",
119
119
  "vite": "^5.0.11",
120
- "vitest": "^1.1.3"
120
+ "vitest": "^1.2.1"
121
121
  }
122
122
  }
@@ -21,7 +21,7 @@ export const Tab = ({
21
21
  fileName,
22
22
  }: {
23
23
  fileId: FileId;
24
- isTransient: boolean;
24
+ isTransient?: boolean;
25
25
  isActive: boolean;
26
26
  setActiveFileId: (fileId: FileId) => void;
27
27
  openTab: (tabState: TabState) => void;
@@ -49,7 +49,7 @@ export const Tab = ({
49
49
 
50
50
  // Double-clicking a transient tab makes it a persistent tab.
51
51
  const handleDoubleClick = useCallback(() => {
52
- openTab({ fileId, isTransient: false });
52
+ openTab({ fileId });
53
53
  }, [fileId, openTab]);
54
54
 
55
55
  return (
@@ -24,10 +24,7 @@ import {
24
24
  EditorCache,
25
25
  useEditorCache,
26
26
  } from './useEditorCache';
27
- import {
28
- useInitialTabs,
29
- usePersistTabs,
30
- } from './tabsSearchParameters';
27
+ import { useURLSync } from './useURLSync';
31
28
 
32
29
  // This context centralizes all the "smart" logic
33
30
  // to do with the application state. This includes
@@ -146,22 +143,11 @@ export const VZCodeProvider = ({
146
143
  typeScriptWorker,
147
144
  });
148
145
 
149
- // Get the initial open tabs from the URL
150
- const {
151
- tabList: initialTabList,
152
- activeFileId: initialActiveFileId,
153
- } = useInitialTabs();
154
-
155
146
  // Set up the reducer that manages much of the application state.
156
147
  // See https://react.dev/reference/react/useReducer
157
148
  const [state, dispatch] = useReducer(
158
149
  vzReducer,
159
- {
160
- defaultTheme,
161
- initialUsername,
162
- initialTabList,
163
- initialActiveFileId,
164
- },
150
+ { defaultTheme, initialUsername },
165
151
  createInitialState,
166
152
  );
167
153
 
@@ -175,9 +161,6 @@ export const VZCodeProvider = ({
175
161
  username,
176
162
  } = state;
177
163
 
178
- // Sync tab state to the URL
179
- usePersistTabs({ activeFileId, tabList });
180
-
181
164
  // Functions for dispatching actions to the reducer.
182
165
  const {
183
166
  setActiveFileId,
@@ -190,6 +173,15 @@ export const VZCodeProvider = ({
190
173
  setUsername,
191
174
  } = useActions(dispatch);
192
175
 
176
+ // Sync tab state to the URL.
177
+ useURLSync({
178
+ content,
179
+ openTab,
180
+ setActiveFileId,
181
+ tabList,
182
+ activeFileId,
183
+ });
184
+
193
185
  // The set of open directories.
194
186
  // TODO move this into reducer/useActions
195
187
  const { isDirectoryOpen, toggleDirectory } =
@@ -1,102 +1,78 @@
1
- import { describe, expect, it, test } from 'vitest';
1
+ import { describe, expect, test } from 'vitest';
2
2
  import {
3
3
  encodeTabs,
4
4
  decodeTabs,
5
- type EditorTabsState,
5
+ delimiter,
6
6
  } from './tabsSearchParameters';
7
+ import { VZCodeContent } from '../types';
7
8
 
8
9
  describe('tabsSearchParameters', () => {
9
10
  test.each([
10
11
  {
11
12
  name: 'empty',
12
- search: '',
13
- state: { tabList: [], activeFileId: null },
13
+ tabStateParams: {},
14
+ tabList: [],
15
+ activeFileId: null,
14
16
  },
15
17
  {
16
- name: 'persistent tab',
17
- search: '123=pa',
18
- state: {
19
- tabList: [{ fileId: '123', isTransient: false }],
20
- activeFileId: '123',
18
+ name: 'single active tab',
19
+ tabStateParams: {
20
+ file: 'index.html',
21
21
  },
22
- },
23
- {
24
- name: 'transient tab',
25
- search: '456=ta',
26
- state: {
27
- tabList: [{ fileId: '456', isTransient: true }],
28
- activeFileId: '456',
29
- },
30
- },
31
- {
32
- name: 'no active file',
33
- search: '678=p',
34
- state: {
35
- tabList: [{ fileId: '678', isTransient: false }],
36
- activeFileId: null,
37
- },
38
- },
39
- {
40
- name: 'three tabs',
41
- search: '111=p&222=ta&333=p',
42
- state: {
43
- tabList: [
44
- { fileId: '111', isTransient: false },
45
- { fileId: '222', isTransient: true },
46
- { fileId: '333', isTransient: false },
47
- ],
48
- activeFileId: '222',
49
- },
50
- },
51
- ])('round trip: $name', ({ search, state }) => {
52
- const decoded = decodeTabs(new URLSearchParams(search));
53
- expect(decoded).toStrictEqual(state);
54
- const encoded = encodeTabs(state).toString();
55
- expect(encoded).toBe(search);
56
- });
57
22
 
58
- test.each([
59
- {
60
- name: 'empty value',
61
- search: 'abc',
62
- correctedSearch: 'abc=p',
63
- state: {
64
- tabList: [{ fileId: 'abc', isTransient: false }],
65
- activeFileId: null,
66
- },
23
+ tabList: [{ fileId: '789' }],
24
+ activeFileId: '789',
67
25
  },
68
26
  {
69
- name: 'unknown characters',
70
- search: '890=xytzaw',
71
- correctedSearch: '890=ta',
72
- state: {
73
- tabList: [{ fileId: '890', isTransient: true }],
74
- activeFileId: '890',
27
+ name: 'multiple tabs, active tab first',
28
+ tabStateParams: {
29
+ file: 'index.html',
30
+ tabs: `index.html${delimiter}README.md${delimiter}index.js`,
75
31
  },
32
+ tabList: [
33
+ { fileId: '789' },
34
+ { fileId: '456' },
35
+ { fileId: '123' },
36
+ ],
37
+ activeFileId: '789',
76
38
  },
77
39
  {
78
- name: 'wrong order',
79
- search: '321=ap',
80
- correctedSearch: '321=pa',
81
- state: {
82
- tabList: [{ fileId: '321', isTransient: false }],
83
- activeFileId: '321',
40
+ name: 'multiple tabs, active tab in middle',
41
+ tabStateParams: {
42
+ file: 'README.md',
43
+ tabs: `index.html${delimiter}README.md${delimiter}index.js`,
84
44
  },
45
+ tabList: [
46
+ { fileId: '789' },
47
+ { fileId: '456' },
48
+ { fileId: '123' },
49
+ ],
50
+ activeFileId: '456',
85
51
  },
86
52
  ])(
87
- 'decode: $name',
88
- ({ search, correctedSearch, state }) => {
89
- const { tabList, activeFileId } = decodeTabs(
90
- new URLSearchParams(),
91
- );
92
- expect(tabList).toStrictEqual([]);
93
- expect(activeFileId).toBe(null);
94
- const decoded = decodeTabs(
95
- new URLSearchParams(search),
96
- );
97
- expect(decoded).toStrictEqual(state);
98
- const corrected = encodeTabs(decoded).toString();
99
- expect(corrected).toBe(correctedSearch);
53
+ 'round trip: $name',
54
+ ({ tabStateParams, tabList, activeFileId }) => {
55
+ // Fake Content object
56
+ const content: VZCodeContent = {
57
+ files: {
58
+ '123': { name: 'index.js', text: 'abc' },
59
+ '456': { name: 'README.md', text: 'def' },
60
+ '789': { name: 'index.html', text: 'ghi' },
61
+ },
62
+ };
63
+
64
+ const decoded = decodeTabs({
65
+ tabStateParams,
66
+ content,
67
+ });
68
+ const expected = { tabList, activeFileId };
69
+ expect(decoded).toStrictEqual(expected);
70
+ const encoded = encodeTabs({
71
+ tabList,
72
+ activeFileId,
73
+ content,
74
+ });
75
+ expect(encoded).toEqual(tabStateParams);
100
76
  },
101
77
  );
102
78
  });
@@ -1,76 +1,137 @@
1
- import { useEffect } from 'react';
2
- import {
3
- useNavigate,
4
- useSearchParams,
5
- } from 'react-router-dom';
6
- import { FileId } from '../types';
1
+ import { FileId, VZCodeContent } from '../types';
7
2
  import { TabState } from './vzReducer';
8
3
 
9
- export interface EditorTabsState {
10
- // The ID of the file that is currently active.
11
- // Invariant: `activeFileId` is always in `tabList`.
12
- activeFileId: FileId | null;
13
-
14
- // The list of open tabs.
15
- tabList: Array<TabState>;
16
- }
4
+ // The delimiter used to separate file names in the `tabs` parameter.
5
+ // We need a character that is both URL-safe (does not get escaped in URLs)
6
+ // and is not typically found in file names, especially for JavaScript and CSS files.
7
+ //
8
+ // The following characters are not URL-safe as they get converted to escaped sequences in URLs,
9
+ // leading to less readable URLs:
10
+ // - `+` becomes `%2B`
11
+ // - `|` becomes `%7C`
12
+ // - `;` becomes `%3B`
13
+ // - `:` becomes `%3A`
14
+ // - `,` becomes `%2C`
15
+ // - `!` becomes `%21`
16
+ // - `/` becomes `%2F`
17
+ //
18
+ // The following characters, while URL-safe, are commonly used in file names,
19
+ // hence using them could lead to conflicts or misinterpretation:
20
+ // - `.` commonly used as a dot separator in file names and extensions
21
+ // - ` ` (space) often found in file names but can lead to issues in URLs
22
+ // - `-` (hyphen) frequently used in file and folder names for readability
23
+ // - `_` (underscore) also commonly used in file and folder names
24
+ //
25
+ // Considering the above constraints, we need a character that is both URL-safe
26
+ // and not typically used in file names. The following character meets these criteria:
27
+ // - `~` is URL-safe (remains unescaped in URLs) and is not a standard character
28
+ // in file names for JavaScript and CSS files, making it a suitable choice for a delimiter.
29
+ export const delimiter = '~';
17
30
 
18
- export const encodeTabs = ({
19
- tabList,
20
- activeFileId,
21
- }: EditorTabsState): URLSearchParams => {
22
- const searchParams = new URLSearchParams();
23
- for (const tab of tabList) {
24
- let tabState = tab.isTransient ? 't' : 'p';
25
- // mark the active file
26
- if (tab.fileId === activeFileId) {
27
- tabState += 'a';
31
+ // Gets the file id of a file with the given name.
32
+ // Returns null if not found.
33
+ const getFileId = (
34
+ content: VZCodeContent,
35
+ fileName: string,
36
+ ): string | null => {
37
+ if (content && content.files) {
38
+ for (const fileId of Object.keys(content.files)) {
39
+ const file = content.files[fileId];
40
+ if (file.name === fileName) {
41
+ return fileId;
42
+ }
28
43
  }
29
- searchParams.set(tab.fileId, tabState);
30
44
  }
31
- return searchParams;
45
+ return null;
32
46
  };
33
47
 
34
- export const decodeTabs = (
35
- searchParams: URLSearchParams,
36
- ): EditorTabsState => {
37
- const tabList: TabState[] = [];
38
- let activeFileId = null;
39
- for (const [fileId, tabState] of Array.from(
40
- searchParams.entries(),
41
- )) {
42
- const isTransient = tabState.includes('t');
43
- const isActive = tabState.includes('a');
44
- if (isActive) {
45
- activeFileId = fileId;
48
+ // Gets the file name of a file with the given id,
49
+ // guard against failure cases.
50
+ // Returns null if not found.
51
+ const getFileName = (
52
+ content: VZCodeContent,
53
+ fileId: string,
54
+ ): string | null => {
55
+ if (content && content.files) {
56
+ const file = content.files[fileId];
57
+ if (file) {
58
+ return file.name;
46
59
  }
47
- tabList.push({ fileId, isTransient });
48
60
  }
49
- return { tabList, activeFileId };
61
+ return null;
50
62
  };
51
63
 
52
- export const useInitialTabs = (): EditorTabsState => {
53
- let [searchParams] = useSearchParams();
64
+ // An object representing the search parameters.
65
+ export type TabStateParams = {
66
+ file?: string;
67
+ tabs?: string;
68
+ };
54
69
 
55
- return decodeTabs(searchParams);
70
+ // Decodes the tab list and active file from the search parameters.
71
+ export const decodeTabs = ({
72
+ tabStateParams,
73
+ content,
74
+ }: {
75
+ tabStateParams: TabStateParams;
76
+ content: VZCodeContent;
77
+ }): {
78
+ tabList: Array<TabState>;
79
+ activeFileId: FileId;
80
+ } => {
81
+ const { file, tabs } = tabStateParams;
82
+ const tabList: TabState[] = [];
83
+
84
+ // Decode active file
85
+ const activeFileId = getFileId(content, file);
86
+
87
+ // Decode tab list
88
+ if (tabs) {
89
+ tabs.split(delimiter).forEach((fileName) => {
90
+ const fileId = getFileId(content, fileName);
91
+ if (fileId) {
92
+ tabList.push({ fileId });
93
+ }
94
+ });
95
+ } else if (activeFileId) {
96
+ // If there's only an active file without a tab list
97
+ tabList.push({ fileId: activeFileId });
98
+ }
99
+
100
+ return { tabList, activeFileId };
56
101
  };
57
102
 
58
- export const usePersistTabs = ({
59
- activeFileId,
103
+ // Encodes the tab list and active file into the search parameters.
104
+ export const encodeTabs = ({
60
105
  tabList,
61
- }: EditorTabsState) => {
62
- const navigate = useNavigate();
63
- useEffect(() => {
64
- navigate(
65
- {
66
- search: encodeTabs({
67
- activeFileId,
68
- tabList,
69
- })
70
- .toString()
71
- .replace(/^\?$/, ''),
72
- },
73
- { replace: true },
74
- );
75
- }, [activeFileId, tabList]);
106
+ activeFileId,
107
+ content,
108
+ }: {
109
+ tabList: Array<TabState>;
110
+ activeFileId: FileId | null;
111
+ content: VZCodeContent;
112
+ }): TabStateParams => {
113
+ // Get the file name of the active file
114
+ const activeFileName = getFileName(content, activeFileId);
115
+
116
+ const params: TabStateParams = {};
117
+
118
+ // If that's missing, no need to proceed (should never happen).
119
+ if (activeFileName) {
120
+ // In any case we set the `file` parameter.
121
+ params.file = activeFileName;
122
+
123
+ // We only need to encode the `tabs` parameter
124
+ // if there's more than one tab.
125
+ if (tabList.length > 1) {
126
+ // Set tab list
127
+ const tabs = tabList
128
+ .map((tabstate: TabState) =>
129
+ getFileName(content, tabstate.fileId),
130
+ )
131
+ .join(delimiter);
132
+ params.tabs = tabs;
133
+ }
134
+ }
135
+
136
+ return params;
76
137
  };
@@ -0,0 +1,134 @@
1
+ import { useSearchParams } from 'react-router-dom';
2
+ import { FileId, VZCodeContent } from '../types';
3
+ import { TabState } from './vzReducer';
4
+ import { useEffect, useMemo, useRef } from 'react';
5
+ import {
6
+ TabStateParams,
7
+ decodeTabs,
8
+ encodeTabs,
9
+ } from './tabsSearchParameters';
10
+
11
+ // Synchronizes the tab state with the URL parameters.
12
+ export const useURLSync = ({
13
+ content,
14
+ openTab,
15
+ setActiveFileId,
16
+ tabList,
17
+ activeFileId,
18
+ }: {
19
+ content: VZCodeContent | null;
20
+ openTab: (tabState: TabState) => void;
21
+ setActiveFileId: (activeFileId: FileId) => void;
22
+ tabList: Array<TabState>;
23
+ activeFileId: FileId | null;
24
+ }) => {
25
+ // Use React router to get and set the search parameters.
26
+ const [searchParams, setSearchParams] = useSearchParams();
27
+
28
+ // Extract the tab state parameters from the search parameters.
29
+ const tabStateParams: TabStateParams = useMemo(
30
+ () => ({
31
+ file: searchParams.get('file'),
32
+ tabs: searchParams.get('tabs'),
33
+ }),
34
+ [searchParams],
35
+ );
36
+
37
+ // `true` after the state is updated based on the
38
+ // initial search parameters from the URL.
39
+ const isInitialized = useRef(false);
40
+
41
+ // Initialize the state based on the search parameters.
42
+ useEffect(() => {
43
+ // Do nothing if the state has already been initialized.
44
+ if (isInitialized.current) {
45
+ return;
46
+ }
47
+
48
+ // Wait for the content to be loaded.
49
+ if (!content) {
50
+ return;
51
+ }
52
+
53
+ // Decode the search parameters.
54
+ const { tabList, activeFileId } = decodeTabs({
55
+ tabStateParams,
56
+ content,
57
+ });
58
+
59
+ // Mark the state as initialized.
60
+ isInitialized.current = true;
61
+
62
+ // Update the state.
63
+ setActiveFileId(activeFileId);
64
+ tabList.forEach(openTab);
65
+ }, [
66
+ content,
67
+ searchParams,
68
+ setActiveFileId,
69
+ openTab,
70
+ isInitialized,
71
+ ]);
72
+
73
+ // Track content in a ref so that we can use it in the
74
+ // effect below without triggering the effect when
75
+ // the content changes.
76
+ const contentRef = useRef(content);
77
+ useEffect(() => {
78
+ contentRef.current = content;
79
+ }, [content]);
80
+
81
+ // Update the URL to match the current state whenever
82
+ // the active file or tab list changes.
83
+ useEffect(() => {
84
+ // Safety check to make sure the content is loaded.
85
+ // Should never happen.
86
+ if (!contentRef.current) {
87
+ return;
88
+ }
89
+
90
+ const newTabStateParams: TabStateParams = encodeTabs({
91
+ tabList,
92
+ activeFileId,
93
+ content: contentRef.current,
94
+ });
95
+
96
+ if (
97
+ tabStateParams.file !== newTabStateParams.file ||
98
+ tabStateParams.tabs !== newTabStateParams.tabs
99
+ ) {
100
+ // Update the URL if the search parameters have changed.
101
+ setSearchParams(
102
+ (oldSearchParams: URLSearchParams) => {
103
+ // Create a copy of the old search params
104
+ const updatedSearchParams = new URLSearchParams(
105
+ oldSearchParams,
106
+ );
107
+
108
+ // Set the 'file' parameter
109
+ if (newTabStateParams.file) {
110
+ updatedSearchParams.set(
111
+ 'file',
112
+ newTabStateParams.file,
113
+ );
114
+ } else {
115
+ updatedSearchParams.delete('file'); // Remove 'file' if it's not present in newTabStateParams
116
+ }
117
+
118
+ // Set the 'tabs' parameter
119
+ if (newTabStateParams.tabs) {
120
+ updatedSearchParams.set(
121
+ 'tabs',
122
+ newTabStateParams.tabs,
123
+ );
124
+ } else {
125
+ updatedSearchParams.delete('tabs'); // Remove 'tabs' if it's not present in newTabStateParams
126
+ }
127
+
128
+ // Return the updated search params
129
+ return updatedSearchParams;
130
+ },
131
+ );
132
+ }
133
+ }, [searchParams, tabList, activeFileId]);
134
+ };
@@ -1,23 +1,16 @@
1
- import { TabState, VZState } from '.';
2
- import { FileId, Username } from '../../types';
1
+ import { VZState } from '.';
2
+ import { Username } from '../../types';
3
3
  import { ThemeLabel } from '../themes';
4
4
 
5
5
  export const createInitialState = ({
6
6
  defaultTheme,
7
7
  initialUsername = 'Anonymous',
8
- initialTabList = [],
9
- initialActiveFileId = null,
10
8
  }: {
11
9
  defaultTheme: ThemeLabel;
12
10
  initialUsername?: Username;
13
- initialTabList?: TabState[];
14
- initialActiveFileId?: FileId;
15
11
  }): VZState => ({
16
- tabList: initialTabList,
17
- activeFileId:
18
- initialActiveFileId ??
19
- initialTabList[0]?.fileId ??
20
- null,
12
+ tabList: [],
13
+ activeFileId: null,
21
14
  theme: defaultTheme,
22
15
  isSettingsOpen: false,
23
16
  editorWantsFocus: false,