vzcode 0.26.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.
- package/dist/assets/index-5f9f376e.css +1 -0
- package/dist/assets/index-70b6d947.js +111 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/client/App.tsx +86 -43
- package/src/client/CodeEditor/getOrCreateEditor.ts +6 -1
- package/src/client/CodeEditor/index.tsx +60 -12
- package/src/client/CodeEditor/json1PresenceBroadcast.ts +6 -2
- package/src/client/CodeEditor/json1PresenceDisplay.ts +0 -45
- package/src/client/CodeEditor/style.scss +5 -1
- package/src/client/CodeErrorOverlay/index.tsx +27 -0
- package/src/client/CodeErrorOverlay/style.scss +30 -0
- package/src/client/PresenceNotifications/PresenceNotifications.test.ts +134 -0
- package/src/client/PresenceNotifications/index.tsx +118 -103
- package/src/client/SplitPaneResizeContext.tsx +12 -7
- package/src/client/TabList/Tab.tsx +20 -6
- package/src/client/TabList/index.tsx +18 -8
- package/src/client/TabList/style.scss +5 -0
- package/src/client/VZSettings.tsx +33 -20
- package/src/client/VZSidebar/{ConfirmationBox.tsx → DeleteConfirmationModal.tsx} +17 -14
- package/src/client/VZSidebar/DirectoryListing.tsx +27 -26
- package/src/client/VZSidebar/FileListing.tsx +16 -19
- package/src/client/VZSidebar/Item.tsx +83 -37
- package/src/client/VZSidebar/Listing.tsx +17 -14
- package/src/client/VZSidebar/index.tsx +43 -24
- package/src/client/style.scss +3 -0
- package/src/client/useActions.ts +25 -6
- package/src/client/useEditorCache.ts +2 -1
- package/src/client/useFileCRUD.ts +29 -27
- package/src/client/usernameLocalStorage.ts +43 -0
- package/src/client/vzReducer/closeTabReducer.test.ts +177 -0
- package/src/client/vzReducer/closeTabsReducer.ts +68 -0
- package/src/client/vzReducer/createInitialState.ts +19 -0
- package/src/client/vzReducer/editorNoLongerWantsFocusReducer.ts +9 -0
- package/src/client/vzReducer/index.ts +107 -0
- package/src/client/vzReducer/openTabReducer.test.ts +110 -0
- package/src/client/vzReducer/openTabReducer.ts +57 -0
- package/src/client/vzReducer/setActiveFileIdReducer.ts +9 -0
- package/src/client/vzReducer/setIsSettingsOpenReducer.ts +9 -0
- package/src/client/vzReducer/setThemeReducer.ts +9 -0
- package/src/client/vzReducer/setUsernameReducer.ts +9 -0
- package/src/index.ts +9 -3
- package/src/types.ts +12 -5
- package/dist/assets/index-284b7339.js +0 -111
- package/dist/assets/index-e160f758.css +0 -1
- package/src/client/PrettierErrorOverlay/index.tsx +0 -9
- package/src/client/PrettierErrorOverlay/style.scss +0 -9
- package/src/client/vzReducer.ts +0 -118
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
FileId,
|
|
4
|
+
FileTreePath,
|
|
5
|
+
VZCodeContent,
|
|
6
|
+
} from '../types';
|
|
3
7
|
import { randomId } from '../randomId';
|
|
4
8
|
|
|
5
9
|
// CRUD operations for files and directories
|
|
@@ -7,6 +11,19 @@ import { randomId } from '../randomId';
|
|
|
7
11
|
export const useFileCRUD = ({
|
|
8
12
|
submitOperation,
|
|
9
13
|
closeTabs,
|
|
14
|
+
openTab,
|
|
15
|
+
}: {
|
|
16
|
+
submitOperation: (
|
|
17
|
+
operation: (document: VZCodeContent) => VZCodeContent,
|
|
18
|
+
) => void;
|
|
19
|
+
closeTabs: (idsToDelete: Array<FileId>) => void;
|
|
20
|
+
openTab: ({
|
|
21
|
+
fileId,
|
|
22
|
+
isTransient,
|
|
23
|
+
}: {
|
|
24
|
+
fileId: FileId;
|
|
25
|
+
isTransient: boolean;
|
|
26
|
+
}) => void;
|
|
10
27
|
}) => {
|
|
11
28
|
// Create a new file
|
|
12
29
|
const createFile = useCallback(() => {
|
|
@@ -14,20 +31,24 @@ export const useFileCRUD = ({
|
|
|
14
31
|
// See https://github.com/vizhub-core/vzcode/issues/252
|
|
15
32
|
const name = prompt('Enter new file name');
|
|
16
33
|
if (name) {
|
|
34
|
+
const fileId: FileId = randomId();
|
|
17
35
|
submitOperation((document: VZCodeContent) => ({
|
|
18
36
|
...document,
|
|
19
37
|
files: {
|
|
20
38
|
...document.files,
|
|
21
|
-
[
|
|
39
|
+
[fileId]: { name, text: '' },
|
|
22
40
|
},
|
|
23
41
|
}));
|
|
42
|
+
// When a new file is created, open it in a new tab
|
|
43
|
+
// and focus the editor on it.
|
|
44
|
+
openTab({ fileId, isTransient: false });
|
|
24
45
|
}
|
|
25
46
|
}, [submitOperation]);
|
|
26
47
|
|
|
27
48
|
// Called when a file in the sidebar is renamed.
|
|
28
|
-
const
|
|
49
|
+
const renameFile = useCallback(
|
|
29
50
|
(fileId: FileId, newName: string) => {
|
|
30
|
-
submitOperation((document) => ({
|
|
51
|
+
submitOperation((document: VZCodeContent) => ({
|
|
31
52
|
...document,
|
|
32
53
|
files: {
|
|
33
54
|
...document.files,
|
|
@@ -56,7 +77,7 @@ export const useFileCRUD = ({
|
|
|
56
77
|
|
|
57
78
|
// Deletes a directory and all files within it
|
|
58
79
|
const deleteDirectory = useCallback(
|
|
59
|
-
(path:
|
|
80
|
+
(path: FileTreePath) => {
|
|
60
81
|
const tabsToClose: Array<FileId> = [];
|
|
61
82
|
submitOperation((document: VZCodeContent) => {
|
|
62
83
|
const updatedFiles = { ...document.files };
|
|
@@ -73,29 +94,10 @@ export const useFileCRUD = ({
|
|
|
73
94
|
[submitOperation, closeTabs],
|
|
74
95
|
);
|
|
75
96
|
|
|
76
|
-
// Generic delete function that calls either deleteFile or deleteDirectory
|
|
77
|
-
// TODO consider if there is a cleaner way to do this
|
|
78
|
-
// - Ideally we would have a separate function for deleting files and directories
|
|
79
|
-
// - When we click the delete button, we should be able to tell if it is a file or directory
|
|
80
|
-
const handleDeleteClick = useCallback(
|
|
81
|
-
(key: string) => {
|
|
82
|
-
// Regex to identify if the key is a file path or a file id.
|
|
83
|
-
if (/^[0-9]*$/.test(key)) {
|
|
84
|
-
if (key.length == 8) {
|
|
85
|
-
deleteFile(key);
|
|
86
|
-
} else {
|
|
87
|
-
deleteDirectory(key);
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
deleteDirectory(key);
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
[deleteFile, deleteDirectory],
|
|
94
|
-
);
|
|
95
|
-
|
|
96
97
|
return {
|
|
97
98
|
createFile,
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
renameFile,
|
|
100
|
+
deleteFile,
|
|
101
|
+
deleteDirectory,
|
|
100
102
|
};
|
|
101
103
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// This module deals with storing and accessing the
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { Username } from '../types';
|
|
5
|
+
|
|
6
|
+
const localStoragePropertyName = 'vzCodeUsername';
|
|
7
|
+
|
|
8
|
+
const initialUsernameDefault = 'Anonymous';
|
|
9
|
+
|
|
10
|
+
// Gets the username from local storage.
|
|
11
|
+
export const useInitialUsername = (): Username => {
|
|
12
|
+
let initialUsername: Username = initialUsernameDefault;
|
|
13
|
+
|
|
14
|
+
// If we're in the browser,
|
|
15
|
+
if (typeof window !== 'undefined') {
|
|
16
|
+
//check localStorage for a previously stored width.
|
|
17
|
+
const initialUsernameFromLocalStorage: string | null =
|
|
18
|
+
window.localStorage.getItem(localStoragePropertyName);
|
|
19
|
+
|
|
20
|
+
// If there is a previously stored width,
|
|
21
|
+
if (initialUsernameFromLocalStorage !== null) {
|
|
22
|
+
// use it as the initial width.
|
|
23
|
+
initialUsername = initialUsernameFromLocalStorage;
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
// If we're not in the browser, use the default initial width.
|
|
27
|
+
}
|
|
28
|
+
return initialUsername;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Stores the username to local storage.
|
|
32
|
+
export const usePersistUsername = (username: Username) => {
|
|
33
|
+
const previousUsername = useRef<Username>(username);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (previousUsername.current !== username) {
|
|
36
|
+
previousUsername.current = username;
|
|
37
|
+
window.localStorage.setItem(
|
|
38
|
+
'vzCodeUsername',
|
|
39
|
+
username,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}, [username]);
|
|
43
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { closeTabsReducer } from './closeTabsReducer';
|
|
3
|
+
import { VZAction, VZState, createInitialState } from '.';
|
|
4
|
+
import { defaultTheme } from '../themes';
|
|
5
|
+
|
|
6
|
+
const initialState: VZState = createInitialState({
|
|
7
|
+
defaultTheme,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('closeTabsReducer', () => {
|
|
11
|
+
it('Closes an existing tab', () => {
|
|
12
|
+
const action: VZAction = {
|
|
13
|
+
type: 'close_tabs',
|
|
14
|
+
fileIdsToClose: ['file1'],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const stateWithTabs = {
|
|
18
|
+
...initialState,
|
|
19
|
+
tabList: [{ fileId: 'file1', isTransient: false }],
|
|
20
|
+
activeFileId: 'file1',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const newState = closeTabsReducer(
|
|
24
|
+
stateWithTabs,
|
|
25
|
+
action,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(newState.tabList.length).toBe(0);
|
|
29
|
+
expect(newState.activeFileId).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('Keeps other tabs open', () => {
|
|
33
|
+
const action: VZAction = {
|
|
34
|
+
type: 'close_tabs',
|
|
35
|
+
fileIdsToClose: ['file2'],
|
|
36
|
+
};
|
|
37
|
+
const stateWithTabs = {
|
|
38
|
+
...initialState,
|
|
39
|
+
tabList: [
|
|
40
|
+
{ fileId: 'file1', isTransient: false },
|
|
41
|
+
{ fileId: 'file2', isTransient: false },
|
|
42
|
+
],
|
|
43
|
+
activeFileId: 'file1',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const newState = closeTabsReducer(
|
|
47
|
+
stateWithTabs,
|
|
48
|
+
action,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(newState.tabList.length).toBe(1);
|
|
52
|
+
expect(newState.tabList[0].fileId).toBe('file1');
|
|
53
|
+
expect(newState.activeFileId).toBe('file1');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('Activates previous tab after closing an active tab', () => {
|
|
57
|
+
const action: VZAction = {
|
|
58
|
+
type: 'close_tabs',
|
|
59
|
+
fileIdsToClose: ['file2'],
|
|
60
|
+
};
|
|
61
|
+
const stateWithTabs = {
|
|
62
|
+
...initialState,
|
|
63
|
+
tabList: [
|
|
64
|
+
{ fileId: 'file1', isTransient: false },
|
|
65
|
+
{ fileId: 'file2', isTransient: false },
|
|
66
|
+
{ fileId: 'file3', isTransient: false },
|
|
67
|
+
],
|
|
68
|
+
activeFileId: 'file2',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const newState = closeTabsReducer(
|
|
72
|
+
stateWithTabs,
|
|
73
|
+
action,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(newState.tabList.length).toBe(2);
|
|
77
|
+
expect(newState.activeFileId).toBe('file1'); // should switch to the previous tab
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('Does not modify the state if closing non-existing tabs', () => {
|
|
81
|
+
const action: VZAction = {
|
|
82
|
+
type: 'close_tabs',
|
|
83
|
+
fileIdsToClose: ['file4'],
|
|
84
|
+
};
|
|
85
|
+
const stateWithTabs = {
|
|
86
|
+
...initialState,
|
|
87
|
+
tabList: [
|
|
88
|
+
{ fileId: 'file1', isTransient: false },
|
|
89
|
+
{ fileId: 'file2', isTransient: false },
|
|
90
|
+
{ fileId: 'file3', isTransient: false },
|
|
91
|
+
],
|
|
92
|
+
activeFileId: 'file2',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const newState = closeTabsReducer(
|
|
96
|
+
stateWithTabs,
|
|
97
|
+
action,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(newState).toEqual(stateWithTabs);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('Closes a tab that is not currently active', () => {
|
|
104
|
+
const stateWithMultipleTabs = {
|
|
105
|
+
...initialState,
|
|
106
|
+
tabList: [
|
|
107
|
+
{ fileId: 'file1', isTransient: false },
|
|
108
|
+
{ fileId: 'file2', isTransient: false },
|
|
109
|
+
{ fileId: 'file3', isTransient: false },
|
|
110
|
+
],
|
|
111
|
+
activeFileId: 'file3',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const action: VZAction = {
|
|
115
|
+
type: 'close_tabs',
|
|
116
|
+
fileIdsToClose: ['file2'],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const newState = closeTabsReducer(
|
|
120
|
+
stateWithMultipleTabs,
|
|
121
|
+
action,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(newState.tabList.length).toBe(2);
|
|
125
|
+
expect(newState.activeFileId).toBe('file3'); // remains unchanged
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('Closes the first tab out of many, when it is active', () => {
|
|
129
|
+
const stateWithMultipleTabs = {
|
|
130
|
+
...initialState,
|
|
131
|
+
tabList: [
|
|
132
|
+
{ fileId: 'file1', isTransient: false },
|
|
133
|
+
{ fileId: 'file2', isTransient: false },
|
|
134
|
+
{ fileId: 'file3', isTransient: false },
|
|
135
|
+
],
|
|
136
|
+
activeFileId: 'file1',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const action: VZAction = {
|
|
140
|
+
type: 'close_tabs',
|
|
141
|
+
fileIdsToClose: ['file1'],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const newState = closeTabsReducer(
|
|
145
|
+
stateWithMultipleTabs,
|
|
146
|
+
action,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(newState.tabList.length).toBe(2);
|
|
150
|
+
expect(newState.activeFileId).toBe('file2'); // switches to the next available tab
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('Closes multiple tabs at once', () => {
|
|
154
|
+
const stateWithMultipleTabs = {
|
|
155
|
+
...initialState,
|
|
156
|
+
tabList: [
|
|
157
|
+
{ fileId: 'file1', isTransient: false },
|
|
158
|
+
{ fileId: 'file2', isTransient: false },
|
|
159
|
+
{ fileId: 'file3', isTransient: false },
|
|
160
|
+
],
|
|
161
|
+
activeFileId: 'file2',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const action: VZAction = {
|
|
165
|
+
type: 'close_tabs',
|
|
166
|
+
fileIdsToClose: ['file1', 'file2'],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const newState = closeTabsReducer(
|
|
170
|
+
stateWithMultipleTabs,
|
|
171
|
+
action,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(newState.tabList.length).toBe(1);
|
|
175
|
+
expect(newState.activeFileId).toBe('file3'); // switches to the next available tab
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { TabState, VZAction, VZState } from '.';
|
|
2
|
+
import { FileId } from '../../types';
|
|
3
|
+
|
|
4
|
+
export const closeTabsReducer = (
|
|
5
|
+
state: VZState,
|
|
6
|
+
action: VZAction,
|
|
7
|
+
): VZState => {
|
|
8
|
+
if (action.type !== 'close_tabs') {
|
|
9
|
+
return state;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const newTabList: Array<TabState> = state.tabList.filter(
|
|
13
|
+
(tabState) =>
|
|
14
|
+
!action.fileIdsToClose.includes(tabState.fileId),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// If the active tab wasn't closed, keep it active.
|
|
18
|
+
if (
|
|
19
|
+
!action.fileIdsToClose.includes(state.activeFileId!)
|
|
20
|
+
) {
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
tabList: newTabList,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const originalIndex = state.tabList.findIndex(
|
|
28
|
+
(tabState) => tabState.fileId === state.activeFileId,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
let newActiveFileId: FileId | null = null;
|
|
32
|
+
|
|
33
|
+
// Try getting the previous available tab first.
|
|
34
|
+
for (let i = originalIndex - 1; i >= 0; i--) {
|
|
35
|
+
if (
|
|
36
|
+
!action.fileIdsToClose.includes(
|
|
37
|
+
state.tabList[i].fileId,
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
newActiveFileId = state.tabList[i].fileId;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// If no previous tab available, get the next available one.
|
|
46
|
+
if (!newActiveFileId) {
|
|
47
|
+
for (
|
|
48
|
+
let i = originalIndex + 1;
|
|
49
|
+
i < state.tabList.length;
|
|
50
|
+
i++
|
|
51
|
+
) {
|
|
52
|
+
if (
|
|
53
|
+
!action.fileIdsToClose.includes(
|
|
54
|
+
state.tabList[i].fileId,
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
newActiveFileId = state.tabList[i].fileId;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...state,
|
|
65
|
+
tabList: newTabList,
|
|
66
|
+
activeFileId: newActiveFileId,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { VZState } from '.';
|
|
2
|
+
import { ThemeLabel } from '../themes';
|
|
3
|
+
|
|
4
|
+
export const createInitialState = ({
|
|
5
|
+
defaultTheme,
|
|
6
|
+
initialUsername,
|
|
7
|
+
}: {
|
|
8
|
+
defaultTheme: ThemeLabel;
|
|
9
|
+
initialUsername?: string;
|
|
10
|
+
}): VZState => ({
|
|
11
|
+
tabList: [],
|
|
12
|
+
activeFileId: null,
|
|
13
|
+
theme: defaultTheme,
|
|
14
|
+
isSettingsOpen: false,
|
|
15
|
+
editorWantsFocus: false,
|
|
16
|
+
|
|
17
|
+
// TODO unify this with App.tsx
|
|
18
|
+
username: initialUsername || 'Anonymous',
|
|
19
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { FileId, Username } from '../../types';
|
|
2
|
+
import { ThemeLabel } from '../themes';
|
|
3
|
+
import { closeTabsReducer } from './closeTabsReducer';
|
|
4
|
+
import { openTabReducer } from './openTabReducer';
|
|
5
|
+
import { setActiveFileIdReducer } from './setActiveFileIdReducer';
|
|
6
|
+
import { setIsSettingsOpenReducer } from './setIsSettingsOpenReducer';
|
|
7
|
+
import { setThemeReducer } from './setThemeReducer';
|
|
8
|
+
import { editorNoLongerWantsFocusReducer } from './editorNoLongerWantsFocusReducer';
|
|
9
|
+
import { setUsernameReducer } from './setUsernameReducer';
|
|
10
|
+
|
|
11
|
+
export { createInitialState } from './createInitialState';
|
|
12
|
+
|
|
13
|
+
// The shape of the state managed by the reducer.
|
|
14
|
+
export type VZState = {
|
|
15
|
+
// The list of open tabs.
|
|
16
|
+
tabList: Array<TabState>;
|
|
17
|
+
|
|
18
|
+
// The ID of the file that is currently active.
|
|
19
|
+
// Invariant: `activeFileId` is always in `tabList`.
|
|
20
|
+
activeFileId: FileId | null;
|
|
21
|
+
|
|
22
|
+
// The theme that is currently active.
|
|
23
|
+
theme: ThemeLabel;
|
|
24
|
+
|
|
25
|
+
// True to show the settings modal.
|
|
26
|
+
isSettingsOpen: boolean;
|
|
27
|
+
|
|
28
|
+
// True if the editor should focus on the next render.
|
|
29
|
+
editorWantsFocus: boolean;
|
|
30
|
+
|
|
31
|
+
// The username of the current user.
|
|
32
|
+
username: Username;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The shape of the actions that can be dispatched to the reducer.
|
|
36
|
+
export type VZAction =
|
|
37
|
+
// `set_active_file_id`
|
|
38
|
+
// * Sets the active file ID.
|
|
39
|
+
| { type: 'set_active_file_id'; activeFileId: FileId }
|
|
40
|
+
|
|
41
|
+
// `open_tab`
|
|
42
|
+
// * Opens a tab.
|
|
43
|
+
// * Also serves to change an already open transient tab to persistent.
|
|
44
|
+
| {
|
|
45
|
+
type: 'open_tab';
|
|
46
|
+
fileId: FileId;
|
|
47
|
+
isTransient: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// `close_tabs`
|
|
51
|
+
// * Closes a set of tabs.
|
|
52
|
+
| { type: 'close_tabs'; fileIdsToClose: Array<FileId> }
|
|
53
|
+
|
|
54
|
+
// `set_theme`
|
|
55
|
+
// * Sets the theme.
|
|
56
|
+
| { type: 'set_theme'; themeLabel: ThemeLabel }
|
|
57
|
+
|
|
58
|
+
// `set_is_settings_open`
|
|
59
|
+
// * Sets whether the settings modal is open.
|
|
60
|
+
| { type: 'set_is_settings_open'; value: boolean }
|
|
61
|
+
|
|
62
|
+
// `editor_no_longer_wants_focus`
|
|
63
|
+
// * Sets `editorWantsFocus` to `false`.
|
|
64
|
+
| { type: 'editor_no_longer_wants_focus' }
|
|
65
|
+
|
|
66
|
+
// `set_username`
|
|
67
|
+
// * Sets the username.
|
|
68
|
+
| { type: 'set_username'; username: Username };
|
|
69
|
+
|
|
70
|
+
// Representation of an open tab.
|
|
71
|
+
export type TabState = {
|
|
72
|
+
// `fileId`
|
|
73
|
+
// The ID of the file that the tab represents.
|
|
74
|
+
fileId: FileId;
|
|
75
|
+
|
|
76
|
+
// `isTransient`
|
|
77
|
+
// Represents whether the tab is temporary or persistent.
|
|
78
|
+
// * `true` if the tab is temporary, meaning its text
|
|
79
|
+
// appears as italic, and it will be automatically
|
|
80
|
+
// closed when the user switches to another file.
|
|
81
|
+
// If `true` and the tab is opened, the editor will not focus.
|
|
82
|
+
// * `false` if the tab is persistent, meaning its text
|
|
83
|
+
// appears as normal, and it will not be automatically
|
|
84
|
+
// closed when the user switches to another file.
|
|
85
|
+
// If `false` and the tab is opened, the editor will focus.
|
|
86
|
+
isTransient: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const reducers = [
|
|
90
|
+
setActiveFileIdReducer,
|
|
91
|
+
openTabReducer,
|
|
92
|
+
closeTabsReducer,
|
|
93
|
+
setThemeReducer,
|
|
94
|
+
setIsSettingsOpenReducer,
|
|
95
|
+
editorNoLongerWantsFocusReducer,
|
|
96
|
+
setUsernameReducer,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export const vzReducer = (
|
|
100
|
+
state: VZState,
|
|
101
|
+
action: VZAction,
|
|
102
|
+
): VZState =>
|
|
103
|
+
reducers.reduce(
|
|
104
|
+
(currentState, currentReducer) =>
|
|
105
|
+
currentReducer(currentState, action),
|
|
106
|
+
state,
|
|
107
|
+
);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { openTabReducer } from './openTabReducer';
|
|
3
|
+
import { VZAction, VZState, createInitialState } from '.';
|
|
4
|
+
import { defaultTheme } from '../themes';
|
|
5
|
+
|
|
6
|
+
const initialState: VZState = createInitialState({
|
|
7
|
+
defaultTheme,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('openTabReducer', () => {
|
|
11
|
+
it('Opens a new tab', () => {
|
|
12
|
+
const action: VZAction = {
|
|
13
|
+
type: 'open_tab',
|
|
14
|
+
fileId: 'file1',
|
|
15
|
+
isTransient: false,
|
|
16
|
+
};
|
|
17
|
+
const newState = openTabReducer(initialState, action);
|
|
18
|
+
|
|
19
|
+
expect(newState.tabList.length).toBe(1);
|
|
20
|
+
expect(newState.tabList[0].fileId).toBe('file1');
|
|
21
|
+
expect(newState.tabList[0].isTransient).toBe(false);
|
|
22
|
+
expect(newState.activeFileId).toBe('file1');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('Replaces a transient tab', () => {
|
|
26
|
+
const stateWithTransientTab = {
|
|
27
|
+
...initialState,
|
|
28
|
+
tabList: [{ fileId: 'file2', isTransient: true }],
|
|
29
|
+
};
|
|
30
|
+
const action: VZAction = {
|
|
31
|
+
type: 'open_tab',
|
|
32
|
+
fileId: 'file1',
|
|
33
|
+
isTransient: true,
|
|
34
|
+
};
|
|
35
|
+
const newState = openTabReducer(
|
|
36
|
+
stateWithTransientTab,
|
|
37
|
+
action,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(newState.tabList.length).toBe(1);
|
|
41
|
+
expect(newState.tabList[0].fileId).toBe('file1');
|
|
42
|
+
expect(newState.tabList[0].isTransient).toBe(true);
|
|
43
|
+
expect(newState.activeFileId).toBe('file1');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('Does not modify a non-transient tab', () => {
|
|
47
|
+
const stateWithNonTransientTab = {
|
|
48
|
+
...initialState,
|
|
49
|
+
tabList: [{ fileId: 'file1', isTransient: false }],
|
|
50
|
+
};
|
|
51
|
+
const action: VZAction = {
|
|
52
|
+
type: 'open_tab',
|
|
53
|
+
fileId: 'file1',
|
|
54
|
+
isTransient: true,
|
|
55
|
+
};
|
|
56
|
+
const newState = openTabReducer(
|
|
57
|
+
stateWithNonTransientTab,
|
|
58
|
+
action,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(newState.tabList.length).toBe(1);
|
|
62
|
+
expect(newState.tabList[0].fileId).toBe('file1');
|
|
63
|
+
expect(newState.tabList[0].isTransient).toBe(false); // should remain non-transient
|
|
64
|
+
expect(newState.activeFileId).toBe('file1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('Activates an already open tab', () => {
|
|
68
|
+
const stateWithMultipleTabs = {
|
|
69
|
+
...initialState,
|
|
70
|
+
tabList: [
|
|
71
|
+
{ fileId: 'file1', isTransient: false },
|
|
72
|
+
{ fileId: 'file2', isTransient: false },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
const action: VZAction = {
|
|
76
|
+
type: 'open_tab',
|
|
77
|
+
fileId: 'file1',
|
|
78
|
+
isTransient: false,
|
|
79
|
+
};
|
|
80
|
+
const newState = openTabReducer(
|
|
81
|
+
stateWithMultipleTabs,
|
|
82
|
+
action,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(newState.tabList.length).toBe(2);
|
|
86
|
+
expect(newState.activeFileId).toBe('file1');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('Sets editorWantsFocus to true for non-transient tabs', () => {
|
|
90
|
+
const action: VZAction = {
|
|
91
|
+
type: 'open_tab',
|
|
92
|
+
fileId: 'file3',
|
|
93
|
+
isTransient: false, // This tab is persistent
|
|
94
|
+
};
|
|
95
|
+
const newState = openTabReducer(initialState, action);
|
|
96
|
+
|
|
97
|
+
expect(newState.editorWantsFocus).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('Sets editorWantsFocus to false for transient tabs', () => {
|
|
101
|
+
const action: VZAction = {
|
|
102
|
+
type: 'open_tab',
|
|
103
|
+
fileId: 'file4',
|
|
104
|
+
isTransient: true, // This tab is transient
|
|
105
|
+
};
|
|
106
|
+
const newState = openTabReducer(initialState, action);
|
|
107
|
+
|
|
108
|
+
expect(newState.editorWantsFocus).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { TabState, VZAction, VZState } from '.';
|
|
2
|
+
|
|
3
|
+
export const openTabReducer = (
|
|
4
|
+
state: VZState,
|
|
5
|
+
action: VZAction,
|
|
6
|
+
): VZState => {
|
|
7
|
+
if (action.type !== 'open_tab') return state;
|
|
8
|
+
|
|
9
|
+
// Check if the tab is already open and if it's transient.
|
|
10
|
+
const existingTabIndex = state.tabList.findIndex(
|
|
11
|
+
(tab) => tab.fileId === action.fileId,
|
|
12
|
+
);
|
|
13
|
+
const tabExists = existingTabIndex !== -1;
|
|
14
|
+
const existingTabIsTransient =
|
|
15
|
+
tabExists &&
|
|
16
|
+
state.tabList[existingTabIndex].isTransient;
|
|
17
|
+
|
|
18
|
+
// Find if there's any other transient tab.
|
|
19
|
+
const transientTabIndex = state.tabList.findIndex(
|
|
20
|
+
(tab) =>
|
|
21
|
+
tab.isTransient && tab.fileId !== action.fileId,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// The new tab state.
|
|
25
|
+
const newTabState: TabState = {
|
|
26
|
+
fileId: action.fileId,
|
|
27
|
+
isTransient: action.isTransient,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let newTabList: Array<TabState>;
|
|
31
|
+
|
|
32
|
+
if (tabExists) {
|
|
33
|
+
if (existingTabIsTransient) {
|
|
34
|
+
newTabList = state.tabList.map((tabState) =>
|
|
35
|
+
tabState.fileId === action.fileId
|
|
36
|
+
? newTabState
|
|
37
|
+
: tabState,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
newTabList = [...state.tabList];
|
|
41
|
+
}
|
|
42
|
+
} else if (transientTabIndex !== -1) {
|
|
43
|
+
newTabList = [...state.tabList];
|
|
44
|
+
newTabList[transientTabIndex] = newTabState;
|
|
45
|
+
} else {
|
|
46
|
+
newTabList = [...state.tabList, newTabState];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...state,
|
|
51
|
+
activeFileId: action.fileId,
|
|
52
|
+
tabList: newTabList,
|
|
53
|
+
|
|
54
|
+
// If the tab is persistent, the editor should focus on the next render.
|
|
55
|
+
editorWantsFocus: !action.isTransient,
|
|
56
|
+
};
|
|
57
|
+
};
|