vzcode 0.49.0 → 0.51.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-dQA3GbFA.js"></script>
28
- <link rel="stylesheet" crossorigin href="/assets/index-jLkbG0kp.css">
27
+ <script type="module" crossorigin src="/assets/index-yk4vJ5U-.js"></script>
28
+ <link rel="stylesheet" crossorigin href="/assets/index-jYtAJn79.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.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -98,24 +98,25 @@
98
98
  "ignore": "^5.3.0",
99
99
  "json0-ot-diff": "^1.1.2",
100
100
  "ngrok": "^4.3.3",
101
- "open": "^10.0.2",
102
- "openai": "^4.24.1",
101
+ "open": "^10.0.3",
102
+ "openai": "^4.24.3",
103
103
  "react": "^18.2.0",
104
104
  "react-bootstrap": "^2.9.2",
105
105
  "react-dom": "^18.2.0",
106
+ "react-router-dom": "^6.21.2",
106
107
  "sharedb": "^4.1.2",
107
108
  "sharedb-client-browser": "^4.4.0",
108
- "vizhub-ui": "^3.15.0",
109
+ "vizhub-ui": "^3.17.0",
109
110
  "ws": "^8.16.0"
110
111
  },
111
112
  "devDependencies": {
112
- "@types/react": "^18.2.46",
113
+ "@types/react": "^18.2.47",
113
114
  "@types/react-dom": "^18.2.18",
114
115
  "@vitejs/plugin-react": "^4.2.1",
115
116
  "prettier": "^3.1.1",
116
- "sass": "^1.69.6",
117
+ "sass": "^1.69.7",
117
118
  "typescript": "^5.3.3",
118
- "vite": "^5.0.10",
119
- "vitest": "^1.1.0"
119
+ "vite": "^5.0.11",
120
+ "vitest": "^1.1.3"
120
121
  }
121
122
  }
@@ -1,4 +1,3 @@
1
- import { useCallback, useEffect } from 'react';
2
1
  import type { TabState } from '../vzReducer';
3
2
  import type { FileId, Files } from '../../types';
4
3
  import { Tab } from './Tab';
@@ -25,18 +24,21 @@ export const TabList = ({
25
24
  return (
26
25
  <div className="vz-tab-list">
27
26
  {files &&
28
- tabList.map((tabState: TabState) => (
29
- <Tab
30
- key={tabState.fileId}
31
- fileId={tabState.fileId}
32
- isTransient={tabState.isTransient}
33
- isActive={tabState.fileId === activeFileId}
34
- setActiveFileId={setActiveFileId}
35
- openTab={openTab}
36
- closeTabs={closeTabs}
37
- fileName={files[tabState.fileId].name}
38
- />
39
- ))}
27
+ tabList
28
+ // Handles the case that the tab list references a file that was deleted.
29
+ .filter((tabState) => tabState.fileId in files)
30
+ .map((tabState: TabState) => (
31
+ <Tab
32
+ key={tabState.fileId}
33
+ fileId={tabState.fileId}
34
+ isTransient={tabState.isTransient}
35
+ isActive={tabState.fileId === activeFileId}
36
+ setActiveFileId={setActiveFileId}
37
+ openTab={openTab}
38
+ closeTabs={closeTabs}
39
+ fileName={files[tabState.fileId].name}
40
+ />
41
+ ))}
40
42
  </div>
41
43
  );
42
44
  };
@@ -24,6 +24,10 @@ import {
24
24
  EditorCache,
25
25
  useEditorCache,
26
26
  } from './useEditorCache';
27
+ import {
28
+ useInitialTabs,
29
+ usePersistTabs,
30
+ } from './tabsSearchParameters';
27
31
 
28
32
  // This context centralizes all the "smart" logic
29
33
  // to do with the application state. This includes
@@ -142,11 +146,22 @@ export const VZCodeProvider = ({
142
146
  typeScriptWorker,
143
147
  });
144
148
 
149
+ // Get the initial open tabs from the URL
150
+ const {
151
+ tabList: initialTabList,
152
+ activeFileId: initialActiveFileId,
153
+ } = useInitialTabs();
154
+
145
155
  // Set up the reducer that manages much of the application state.
146
156
  // See https://react.dev/reference/react/useReducer
147
157
  const [state, dispatch] = useReducer(
148
158
  vzReducer,
149
- { defaultTheme, initialUsername },
159
+ {
160
+ defaultTheme,
161
+ initialUsername,
162
+ initialTabList,
163
+ initialActiveFileId,
164
+ },
150
165
  createInitialState,
151
166
  );
152
167
 
@@ -160,6 +175,9 @@ export const VZCodeProvider = ({
160
175
  username,
161
176
  } = state;
162
177
 
178
+ // Sync tab state to the URL
179
+ usePersistTabs({ activeFileId, tabList });
180
+
163
181
  // Functions for dispatching actions to the reducer.
164
182
  const {
165
183
  setActiveFileId,
@@ -1,4 +1,4 @@
1
- import { useContext } from 'react';
1
+ import { useContext, useEffect, useState } from 'react';
2
2
  import { SplitPaneResizeContext } from './SplitPaneResizeContext';
3
3
  import { TabList } from './TabList';
4
4
  import { CodeEditor } from './CodeEditor';
@@ -21,8 +21,8 @@ export const VZMiddle = ({
21
21
  aiAssistClickOverride,
22
22
  }: {
23
23
  enableAIAssist?: boolean;
24
- aiAssistEndpoint: string;
25
- aiAssistOptions: { [key: string]: string };
24
+ aiAssistEndpoint?: string;
25
+ aiAssistOptions?: { [key: string]: string };
26
26
  aiAssistTooltipText?: string;
27
27
  aiAssistClickOverride?: () => void;
28
28
  }) => {
@@ -53,6 +53,13 @@ export const VZMiddle = ({
53
53
  typeScriptWorker,
54
54
  } = useContext(VZCodeContext);
55
55
 
56
+ // This prevents the CodeEditor from rendering
57
+ // during SSR.
58
+ const [isClient, setIsClient] = useState(false);
59
+ useEffect(() => {
60
+ setIsClient(true);
61
+ }, []);
62
+
56
63
  return activeFileId !== null ? (
57
64
  <div
58
65
  className="middle"
@@ -67,7 +74,7 @@ export const VZMiddle = ({
67
74
  closeTabs={closeTabs}
68
75
  createFile={createFile}
69
76
  />
70
- {content && activeFileId ? (
77
+ {isClient && content && activeFileId && (
71
78
  <CodeEditor
72
79
  shareDBDoc={shareDBDoc}
73
80
  submitOperation={submitOperation}
@@ -83,8 +90,11 @@ export const VZMiddle = ({
83
90
  username={username}
84
91
  typeScriptWorker={typeScriptWorker}
85
92
  />
86
- ) : null}
87
- {enableAIAssist && content && activeFileId ? (
93
+ )}
94
+ {isClient &&
95
+ enableAIAssist &&
96
+ content &&
97
+ activeFileId ? (
88
98
  <AIAssistWidget
89
99
  activeFileId={activeFileId}
90
100
  shareDBDoc={shareDBDoc}
@@ -26,7 +26,13 @@ const resizerInteractionSurfaceWidthWhileDragging = 200;
26
26
  // This is the part of the resizer that is visible to the user.
27
27
  const resizerThumbWidth = 4;
28
28
 
29
- export const VZResizer = ({ side }: { side: Side }) => {
29
+ export const VZResizer = ({
30
+ side,
31
+ isSidebarVisible = true,
32
+ }: {
33
+ side: Side;
34
+ isSidebarVisible?: boolean;
35
+ }) => {
30
36
  const {
31
37
  sidebarWidth,
32
38
  codeEditorWidth,
@@ -96,7 +102,9 @@ export const VZResizer = ({ side }: { side: Side }) => {
96
102
  const left =
97
103
  (side === 'left'
98
104
  ? sidebarWidth
99
- : sidebarWidth + codeEditorWidth) -
105
+ : isSidebarVisible
106
+ ? sidebarWidth + codeEditorWidth
107
+ : codeEditorWidth) -
100
108
  resizerWidth / 2;
101
109
 
102
110
  return shouldRenderResizer ? (
@@ -1,9 +1,12 @@
1
1
  import * as React from 'react';
2
2
  import * as ReactDOM from 'react-dom/client';
3
3
  import App from './App';
4
+ import { BrowserRouter } from 'react-router-dom';
4
5
 
5
6
  ReactDOM.createRoot(document.getElementById('root')).render(
6
7
  // <React.StrictMode>
7
- <App />,
8
+ <BrowserRouter>
9
+ <App />
10
+ </BrowserRouter>,
8
11
  // </React.StrictMode>,
9
12
  );
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it, test } from 'vitest';
2
+ import {
3
+ encodeTabs,
4
+ decodeTabs,
5
+ type EditorTabsState,
6
+ } from './tabsSearchParameters';
7
+
8
+ describe('tabsSearchParameters', () => {
9
+ test.each([
10
+ {
11
+ name: 'empty',
12
+ search: '',
13
+ state: { tabList: [], activeFileId: null },
14
+ },
15
+ {
16
+ name: 'persistent tab',
17
+ search: '123=pa',
18
+ state: {
19
+ tabList: [{ fileId: '123', isTransient: false }],
20
+ activeFileId: '123',
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
+
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
+ },
67
+ },
68
+ {
69
+ name: 'unknown characters',
70
+ search: '890=xytzaw',
71
+ correctedSearch: '890=ta',
72
+ state: {
73
+ tabList: [{ fileId: '890', isTransient: true }],
74
+ activeFileId: '890',
75
+ },
76
+ },
77
+ {
78
+ name: 'wrong order',
79
+ search: '321=ap',
80
+ correctedSearch: '321=pa',
81
+ state: {
82
+ tabList: [{ fileId: '321', isTransient: false }],
83
+ activeFileId: '321',
84
+ },
85
+ },
86
+ ])(
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);
100
+ },
101
+ );
102
+ });
@@ -0,0 +1,76 @@
1
+ import { useEffect } from 'react';
2
+ import {
3
+ useNavigate,
4
+ useSearchParams,
5
+ } from 'react-router-dom';
6
+ import { FileId } from '../types';
7
+ import { TabState } from './vzReducer';
8
+
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
+ }
17
+
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';
28
+ }
29
+ searchParams.set(tab.fileId, tabState);
30
+ }
31
+ return searchParams;
32
+ };
33
+
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;
46
+ }
47
+ tabList.push({ fileId, isTransient });
48
+ }
49
+ return { tabList, activeFileId };
50
+ };
51
+
52
+ export const useInitialTabs = (): EditorTabsState => {
53
+ let [searchParams] = useSearchParams();
54
+
55
+ return decodeTabs(searchParams);
56
+ };
57
+
58
+ export const usePersistTabs = ({
59
+ activeFileId,
60
+ 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]);
76
+ };
@@ -1,16 +1,23 @@
1
- import { VZState } from '.';
2
- import { Username } from '../../types';
1
+ import { TabState, VZState } from '.';
2
+ import { FileId, 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,
8
10
  }: {
9
11
  defaultTheme: ThemeLabel;
10
12
  initialUsername?: Username;
13
+ initialTabList?: TabState[];
14
+ initialActiveFileId?: FileId;
11
15
  }): VZState => ({
12
- tabList: [],
13
- activeFileId: null,
16
+ tabList: initialTabList,
17
+ activeFileId:
18
+ initialActiveFileId ??
19
+ initialTabList[0]?.fileId ??
20
+ null,
14
21
  theme: defaultTheme,
15
22
  isSettingsOpen: false,
16
23
  editorWantsFocus: false,