mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-07-28 04:08:05 +00:00
fileStorage: use dexie-react-hooks
This lets the dexie-react-hooks library do more of the work for us instead of having to make redux-sagas wrappers for everything.
This commit is contained in:
@@ -124,6 +124,7 @@ const licenseTextOverrides = {
|
||||
'@shopify/react-hooks': shopifyLicense,
|
||||
'@shopify/react-i18n': shopifyLicense,
|
||||
'dexie-observable': dexieLicense,
|
||||
'dexie-react-hooks': dexieLicense,
|
||||
'bn.js': fedorIndutnyLicense(2015),
|
||||
brorand: fedorIndutnyLicense(2014),
|
||||
'des.js': fedorIndutnyLicense(2015),
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"css-minimizer-webpack-plugin": "^3.2.0",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-observable": "^4.0.0-beta.13",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"dotenv-expand": "^5.1.0",
|
||||
"eslint": "^8.3.0",
|
||||
|
||||
@@ -5,6 +5,8 @@ import { cleanup } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { testRender, uuid } from '../../test';
|
||||
import { FileMetadata } from '../fileStorage';
|
||||
import { useFileStorageMetadata } from '../fileStorage/hooks';
|
||||
import Explorer from './Explorer';
|
||||
import {
|
||||
explorerActivateFile,
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
explorerExportFile,
|
||||
explorerImportFiles,
|
||||
} from './actions';
|
||||
import { ExplorerFileInfo } from './reducers';
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
@@ -23,16 +24,16 @@ afterEach(async () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const testFile: ExplorerFileInfo = {
|
||||
id: uuid(0),
|
||||
name: 'test.file',
|
||||
const testFile: FileMetadata = {
|
||||
uuid: uuid(0),
|
||||
path: 'test.file',
|
||||
sha256: '',
|
||||
};
|
||||
|
||||
describe('archive button', () => {
|
||||
it('should dispatch action when clicked', () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const button = explorer.getByTitle('Backup all files');
|
||||
expect(button).toBeEnabled();
|
||||
@@ -66,9 +67,8 @@ describe('new file button', () => {
|
||||
|
||||
describe('tree item', () => {
|
||||
it('should dispatch action when clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
@@ -78,9 +78,8 @@ describe('tree item', () => {
|
||||
});
|
||||
|
||||
it('should dispatch action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
@@ -92,9 +91,8 @@ describe('tree item', () => {
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('should dispatch action when button is clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
@@ -109,9 +107,8 @@ describe('tree item', () => {
|
||||
});
|
||||
|
||||
it('should dispatch action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
@@ -124,9 +121,8 @@ describe('tree item', () => {
|
||||
|
||||
describe('export', () => {
|
||||
it('should dispatch export action when button is clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
@@ -141,9 +137,8 @@ describe('tree item', () => {
|
||||
});
|
||||
|
||||
it('should dispatch export action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
@@ -156,9 +151,8 @@ describe('tree item', () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should dispatch delete action when button is clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
@@ -173,9 +167,8 @@ describe('tree item', () => {
|
||||
});
|
||||
|
||||
it('should dispatch delete action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
const [explorer, dispatch] = testRender(<Explorer />);
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Toolbar } from '../components/toolbar/Toolbar';
|
||||
import { useToolbarItemFocus } from '../components/toolbar/aria';
|
||||
import { useSelector } from '../reducers';
|
||||
import { useFileStorageMetadata } from '../fileStorage/hooks';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
|
||||
import {
|
||||
@@ -308,7 +308,7 @@ type FileTreeProps = {
|
||||
|
||||
const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
|
||||
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
|
||||
const files = useSelector((s) => s.explorer.files);
|
||||
const files = useFileStorageMetadata() ?? [];
|
||||
const liveDescriptors = useLiveDescriptors(i18n);
|
||||
|
||||
const rootItemIndex = 'root';
|
||||
@@ -317,12 +317,12 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
|
||||
() =>
|
||||
files.reduce(
|
||||
(obj, file) => {
|
||||
const index = file.id;
|
||||
const index = file.uuid;
|
||||
|
||||
obj[index] = {
|
||||
index,
|
||||
data: {
|
||||
fileName: file.name,
|
||||
fileName: file.path,
|
||||
icon: 'document',
|
||||
secondaryLabel: (
|
||||
<TreeItemContext.Consumer>
|
||||
@@ -344,10 +344,7 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
|
||||
index: rootItemIndex,
|
||||
data: { fileName: '/' },
|
||||
hasChildren: true,
|
||||
children: [...files]
|
||||
// REVISIT: consider using Intl.Collator() for i18n.locale
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((n) => n.id),
|
||||
children: [...files].map((f) => f.uuid),
|
||||
},
|
||||
} as Record<TreeItemIndex, FileTreeItem>,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button, Classes, Dialog } from '@blueprintjs/core';
|
||||
import { useI18n } from '@shopify/react-i18n';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import {
|
||||
FileNameValidationResult,
|
||||
validateFileName,
|
||||
@@ -24,11 +25,11 @@ const DuplicateFileDialog: React.VFC = () => {
|
||||
const [baseName, extension] = oldName.split(/(\.\w+)$/);
|
||||
|
||||
const [newName, setNewName] = useState(baseName);
|
||||
const files = useSelector((s) => s.explorer.files);
|
||||
const files = useFileStorageMetadata() ?? [];
|
||||
const result = validateFileName(
|
||||
newName,
|
||||
extension,
|
||||
files.map((f) => f.name),
|
||||
files.map((f) => f.path),
|
||||
);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useI18n } from '@shopify/react-i18n';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import {
|
||||
FileNameValidationResult,
|
||||
pythonFileExtension,
|
||||
@@ -33,11 +34,11 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
|
||||
|
||||
const isOpen = useSelector((s) => s.explorer.newFileWizard.isOpen);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const files = useSelector((s) => s.explorer.files);
|
||||
const files = useFileStorageMetadata() ?? [];
|
||||
const fileNameValidation = validateFileName(
|
||||
fileName,
|
||||
pythonFileExtension,
|
||||
files.map((f) => f.name),
|
||||
files.map((f) => f.path),
|
||||
);
|
||||
const [hubType, setHubType] = useState(defaultHub);
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { uuid } from '../../test';
|
||||
import {
|
||||
FileMetadata,
|
||||
fileStorageDidAddItem,
|
||||
fileStorageDidChangeItem,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidRemoveItem,
|
||||
} from '../fileStorage/actions';
|
||||
import reducers, { ExplorerFileInfo } from './reducers';
|
||||
|
||||
type State = ReturnType<typeof reducers>;
|
||||
|
||||
describe('files', () => {
|
||||
const testFile: ExplorerFileInfo = {
|
||||
id: uuid(0),
|
||||
name: 'test.file',
|
||||
};
|
||||
|
||||
const testFileMetadata: FileMetadata = {
|
||||
uuid: uuid(0),
|
||||
path: 'test.file',
|
||||
sha256: '',
|
||||
};
|
||||
|
||||
const modifiedFile: ExplorerFileInfo = {
|
||||
...testFile,
|
||||
name: 'modified.file',
|
||||
};
|
||||
|
||||
const modifiedFileMetadata: FileMetadata = {
|
||||
...testFileMetadata,
|
||||
path: 'modified.file',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// check validity of test data before starting tests
|
||||
expect(testFile).not.toEqual(modifiedFile);
|
||||
expect(testFileMetadata).not.toEqual(modifiedFileMetadata);
|
||||
});
|
||||
|
||||
it('should get a list when file storage is initialized', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ files: [] as readonly ExplorerFileInfo[] } as State,
|
||||
fileStorageDidInitialize([testFileMetadata]),
|
||||
).files,
|
||||
).toEqual([testFile]);
|
||||
});
|
||||
|
||||
it('should modify the list when a file is added to storage', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ files: [] as readonly ExplorerFileInfo[] } as State,
|
||||
fileStorageDidAddItem(testFileMetadata),
|
||||
).files,
|
||||
).toEqual([testFile]);
|
||||
});
|
||||
|
||||
it('should modify the list when an item is renamed in storage', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ files: [testFile] as readonly ExplorerFileInfo[] } as State,
|
||||
fileStorageDidChangeItem(testFileMetadata, modifiedFileMetadata),
|
||||
).files,
|
||||
).toEqual([modifiedFile]);
|
||||
});
|
||||
|
||||
it('should not modify the list if a change is made other than renaming', () => {
|
||||
const originalList: readonly ExplorerFileInfo[] = [testFile];
|
||||
expect(
|
||||
reducers(
|
||||
{ files: originalList } as State,
|
||||
fileStorageDidChangeItem(testFileMetadata, {
|
||||
...testFileMetadata,
|
||||
sha256: 'changed',
|
||||
}),
|
||||
).files,
|
||||
).toBe(originalList);
|
||||
});
|
||||
|
||||
it('should modify the list when a file is removed from storage', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ files: [testFile] as readonly ExplorerFileInfo[] } as State,
|
||||
fileStorageDidRemoveItem(testFileMetadata),
|
||||
).files,
|
||||
).not.toContain(testFile);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,14 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import {
|
||||
FileMetadata,
|
||||
fileStorageDidAddItem,
|
||||
fileStorageDidChangeItem,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidRemoveItem,
|
||||
} from '../fileStorage/actions';
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import deleteFileAlert from './deleteFileAlert/reducers';
|
||||
import duplicateFileDialog from './duplicateFileDialog/reducers';
|
||||
import newFileWizard from './newFileWizard/reducers';
|
||||
import renameFileDialog from './renameFileDialog/reducers';
|
||||
|
||||
export type ExplorerFileInfo = Readonly<{
|
||||
/** A unique identifier for this file (not the path, which can change). */
|
||||
id: string;
|
||||
/** The file name (including extension - without directory). */
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
function metadataToInfo(file: FileMetadata): ExplorerFileInfo {
|
||||
return { id: file.uuid, name: file.path };
|
||||
}
|
||||
|
||||
const files: Reducer<readonly ExplorerFileInfo[]> = (state = [], action) => {
|
||||
if (fileStorageDidInitialize.matches(action)) {
|
||||
return action.files.map(metadataToInfo);
|
||||
}
|
||||
|
||||
if (fileStorageDidAddItem.matches(action)) {
|
||||
return [...state, metadataToInfo(action.file)];
|
||||
}
|
||||
|
||||
if (fileStorageDidChangeItem.matches(action)) {
|
||||
// We only care about UUID and the path. UUID can't change so if the
|
||||
// path didn't change, there is nothing to do.
|
||||
if (action.oldFile.path === action.file.path) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return state.map((f) =>
|
||||
f.id === action.file.uuid ? metadataToInfo(action.file) : f,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileStorageDidRemoveItem.matches(action)) {
|
||||
return state.filter((value) => value.id !== action.file.uuid);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
files,
|
||||
duplicateFileDialog,
|
||||
deleteFileAlert,
|
||||
newFileWizard,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button, Classes, Dialog } from '@blueprintjs/core';
|
||||
import { useI18n } from '@shopify/react-i18n';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import {
|
||||
FileNameValidationResult,
|
||||
validateFileName,
|
||||
@@ -24,11 +25,11 @@ const RenameFileDialog: React.VFC = () => {
|
||||
const [baseName, extension] = oldName.split(/(\.\w+)$/);
|
||||
|
||||
const [newName, setNewName] = useState(baseName);
|
||||
const files = useSelector((s) => s.explorer.files);
|
||||
const files = useFileStorageMetadata() ?? [];
|
||||
const result = validateFileName(
|
||||
newName,
|
||||
extension,
|
||||
files.map((f) => f.name),
|
||||
files.map((f) => f.path),
|
||||
);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import * as browserFsAccess from 'browser-fs-access';
|
||||
import { FileWithHandle } from 'browser-fs-access';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import {
|
||||
editorActivateFile,
|
||||
editorCloseFile,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRemoveItem,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageReadFile,
|
||||
@@ -399,22 +398,6 @@ describe('handleExplorerDeleteFile', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail with AbortError if file was removed before user accept/cancel', async () => {
|
||||
saga.put(
|
||||
fileStorageDidRemoveItem({ path: testFile, uuid: uuid(0), sha256: '' }),
|
||||
);
|
||||
|
||||
// should programmatically cancel the dialog
|
||||
await expect(saga.take()).resolves.toEqual(deleteFileAlertDidCancel());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToDeleteFile(
|
||||
testFile,
|
||||
new DOMException('file was removed', 'AbortError'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('accepted', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(deleteFileAlertDidAccept());
|
||||
|
||||
+3
-20
@@ -3,7 +3,7 @@
|
||||
|
||||
import { fileOpen, fileSave } from 'browser-fs-access';
|
||||
import JSZip from 'jszip';
|
||||
import { call, put, race, select, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { call, put, race, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import {
|
||||
editorActivateFile,
|
||||
editorCloseFile,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRemoveItem,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageReadFile,
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
pythonFileMimeType,
|
||||
validateFileName,
|
||||
} from '../pybricksMicropython/lib';
|
||||
import { RootState } from '../reducers';
|
||||
import { defined, ensureError, timestamp } from '../utils';
|
||||
import {
|
||||
explorerActivateFile,
|
||||
@@ -143,13 +141,8 @@ function* handleExplorerImportFiles(): Generator {
|
||||
const text = yield* call(() => file.text());
|
||||
|
||||
const [baseName] = file.name.split(pythonFileExtensionRegex);
|
||||
const existingFiles = yield* select((s: RootState) => s.explorer.files);
|
||||
|
||||
const result = validateFileName(
|
||||
baseName,
|
||||
pythonFileExtension,
|
||||
existingFiles.map((f) => f.name),
|
||||
);
|
||||
const result = validateFileName(baseName, pythonFileExtension, []);
|
||||
|
||||
if (result != FileNameValidationResult.IsOk) {
|
||||
// TODO: validate file name and allow user to rename or skip
|
||||
@@ -347,25 +340,15 @@ function* handleExplorerDeleteFile(action: ReturnType<typeof explorerDeleteFile>
|
||||
try {
|
||||
yield* put(deleteFileAlertShow(action.fileName));
|
||||
|
||||
const { didCancel, didRemove } = yield* race({
|
||||
const { didCancel } = yield* race({
|
||||
didAccept: take(deleteFileAlertDidAccept),
|
||||
didCancel: take(deleteFileAlertDidCancel),
|
||||
didRemove: take(
|
||||
fileStorageDidRemoveItem.when((a) => a.file.path === action.fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didCancel) {
|
||||
throw new DOMException('user canceled', 'AbortError');
|
||||
}
|
||||
|
||||
// automatically cancel the dialog, if the file was removed, e.g. it was
|
||||
// deleted in a different window
|
||||
if (didRemove) {
|
||||
yield* put(deleteFileAlertDidCancel());
|
||||
throw new DOMException('file was removed', 'AbortError');
|
||||
}
|
||||
|
||||
// at this point we know the user accepted
|
||||
|
||||
// have to close editor before deleting, otherwise we get "in use" error
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createAction } from '../actions';
|
||||
import { FileMetadata } from '.';
|
||||
|
||||
/** File open modes. */
|
||||
export type FileOpenMode = 'r' | 'w';
|
||||
@@ -9,23 +10,6 @@ export type FileOpenMode = 'r' | 'w';
|
||||
/** Type to avoid mixing up file descriptor with number. */
|
||||
export type FD = number & { _fdBrand: undefined };
|
||||
|
||||
/** Type to avoid mixing UUID with regular string. */
|
||||
export type UUID = string & { _uuidBrand: undefined };
|
||||
|
||||
/**
|
||||
* Database metadata table data type.
|
||||
*
|
||||
* IMPORTANT: if this type is changed, we need to modify the database schema to match
|
||||
*/
|
||||
export type FileMetadata = Readonly<{
|
||||
/** A globally unique identifier that serves a a file handle. */
|
||||
uuid: UUID;
|
||||
/** The path of the file in storage. */
|
||||
path: string;
|
||||
/** The SHA256 hash of the file contents. */
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Action that indicates that the storage backend is ready to use.
|
||||
* @param files List of all files currently in storage.
|
||||
@@ -46,37 +30,6 @@ export const fileStorageDidFailToInitialize = createAction((error: Error) => ({
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates that an item in the storage was created by us or in another tab.
|
||||
* @param file The file metadata.
|
||||
*/
|
||||
export const fileStorageDidAddItem = createAction((file: FileMetadata) => ({
|
||||
type: 'fileStorage.action.didAddItem',
|
||||
file,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates that an item in the storage was changed by us or in another tab.
|
||||
* @param file The old file metadata.
|
||||
* @param file The file metadata.
|
||||
*/
|
||||
export const fileStorageDidChangeItem = createAction(
|
||||
(oldFile: FileMetadata, file: FileMetadata) => ({
|
||||
type: 'fileStorage.action.didChangeItem',
|
||||
oldFile,
|
||||
file,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Action that indicates that an item in the storage was removed by us or in another tab.
|
||||
* @param file The file metadata.
|
||||
*/
|
||||
export const fileStorageDidRemoveItem = createAction((file: FileMetadata) => ({
|
||||
type: 'fileStorage.action.didRemoveItem',
|
||||
file,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that requests to open a file in storage.
|
||||
* @param path The file path.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createContext } from 'react';
|
||||
import { FileStorageDb } from '.';
|
||||
|
||||
export const db = new FileStorageDb('pybricks.fileStorage');
|
||||
|
||||
export const FileStorageContext = createContext(db);
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { useContext } from 'react';
|
||||
import { FileStorageContext } from './context';
|
||||
import { FileMetadata, UUID } from '.';
|
||||
|
||||
/**
|
||||
* Gets all file metadata for all files currently in storage.
|
||||
*
|
||||
* The returned array is sorted by the path.
|
||||
*/
|
||||
export function useFileStorageMetadata(): FileMetadata[] | undefined {
|
||||
const db = useContext(FileStorageContext);
|
||||
return useLiveQuery(() => db.metadata.orderBy('path').toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file path for a file UUID.
|
||||
*
|
||||
* If the file is renamed, the returned value will be automatically updated.
|
||||
*/
|
||||
export function useFileStoragePath(uuid: UUID): string | undefined {
|
||||
const db = useContext(FileStorageContext);
|
||||
return useLiveQuery(() => db.metadata.get(uuid, (x) => x?.path));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import 'dexie-observable';
|
||||
import Dexie, { Table } from 'dexie';
|
||||
|
||||
/** Type to avoid mixing UUID with regular string. */
|
||||
export type UUID = string & { _uuidBrand: undefined };
|
||||
|
||||
/**
|
||||
* Database metadata table data type.
|
||||
*
|
||||
* IMPORTANT: if this type is changed, we need to modify the database schema to match
|
||||
*/
|
||||
export type FileMetadata = Readonly<{
|
||||
/** A globally unique identifier that serves a a file handle. */
|
||||
uuid: UUID;
|
||||
/** The path of the file in storage. */
|
||||
path: string;
|
||||
/** The SHA256 hash of the file contents. */
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Database contents table data type.
|
||||
*
|
||||
* IMPORTANT: if this type is changed, we need to modify the database schema to match
|
||||
*/
|
||||
type FileContents = {
|
||||
/** The path of the file in storage. */
|
||||
path: string;
|
||||
/** The contents of the file. */
|
||||
contents: string;
|
||||
};
|
||||
|
||||
export class FileStorageDb extends Dexie {
|
||||
metadata!: Table<FileMetadata, UUID>;
|
||||
// NB: This table starts with an underscore to hide it from Dexie observable.
|
||||
// In the future we may change this to use File Access API or some other
|
||||
// storage, so we don't want to rely on the file contents being included
|
||||
// with the metadata.
|
||||
_contents!: Table<FileContents, string>;
|
||||
|
||||
constructor(databaseName: string) {
|
||||
super(databaseName);
|
||||
this.version(1).stores({
|
||||
metadata: '$$uuid, &path, sha256',
|
||||
_contents: 'path, contents',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,10 @@ import { AsyncSaga, uuid } from '../../test';
|
||||
import { createCountFunc } from '../utils/iter';
|
||||
import {
|
||||
FD,
|
||||
FileMetadata,
|
||||
FileOpenMode,
|
||||
fileStorageClose,
|
||||
fileStorageCopyFile,
|
||||
fileStorageDeleteFile,
|
||||
fileStorageDidAddItem,
|
||||
fileStorageDidChangeItem,
|
||||
fileStorageDidClose,
|
||||
fileStorageDidCopyFile,
|
||||
fileStorageDidDeleteFile,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
fileStorageDidOpen,
|
||||
fileStorageDidRead,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRemoveItem,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWrite,
|
||||
fileStorageDidWriteFile,
|
||||
@@ -46,10 +42,7 @@ import {
|
||||
fileStorageWriteFile,
|
||||
} from './actions';
|
||||
import fileStorage from './sagas';
|
||||
|
||||
/** SHA256 hash of '' */
|
||||
const emptyFileSha256 =
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
import { FileMetadata, FileStorageDb } from '.';
|
||||
|
||||
beforeEach(() => {
|
||||
// deterministic UUID generator for repeatable tests
|
||||
@@ -61,7 +54,7 @@ afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase('pybricks.fileStorage');
|
||||
const request = indexedDB.deleteDatabase('test');
|
||||
request.addEventListener('success', resolve);
|
||||
request.addEventListener('error', reject);
|
||||
});
|
||||
@@ -87,8 +80,6 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> {
|
||||
sha256: testFileContentsSha256,
|
||||
};
|
||||
|
||||
const emptyFile: FileMetadata = { ...testFile, sha256: emptyFileSha256 };
|
||||
|
||||
saga.put(fileStorageOpen(testFilePath, 'w', true));
|
||||
|
||||
const didOpen = await saga.take();
|
||||
@@ -97,14 +88,9 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> {
|
||||
fail(didOpen);
|
||||
}
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidAddItem(emptyFile));
|
||||
|
||||
saga.put(fileStorageWrite(didOpen.fd, testFileContents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidWrite(didOpen.fd));
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidChangeItem(emptyFile, testFile),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(didOpen.fd));
|
||||
|
||||
@@ -124,7 +110,9 @@ describe('initialize', () => {
|
||||
localStorage.setItem(oldProgramKey, oldProgramContents);
|
||||
expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents);
|
||||
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
const saga = new AsyncSaga(fileStorage, {
|
||||
fileStorage: new FileStorageDb('test'),
|
||||
});
|
||||
|
||||
// initialization should remove the localStorage entry and add add it to
|
||||
// new storage backend
|
||||
@@ -145,7 +133,9 @@ describe('initialize', () => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
const saga = new AsyncSaga(fileStorage, {
|
||||
fileStorage: new FileStorageDb('test'),
|
||||
});
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToInitialize(testError),
|
||||
@@ -159,7 +149,7 @@ describe('open', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
});
|
||||
@@ -182,14 +172,6 @@ describe('open', () => {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 0 as FD),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidAddItem({
|
||||
uuid: uuid(0),
|
||||
path: 'test.file',
|
||||
sha256: emptyFileSha256,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should fail to open if file is already open for writing', () => {
|
||||
@@ -268,14 +250,6 @@ describe('open', () => {
|
||||
fileStorageDidOpen('test.file', 0 as FD),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidAddItem({
|
||||
uuid: uuid(0),
|
||||
path: 'test.file',
|
||||
sha256: emptyFileSha256,
|
||||
}),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD));
|
||||
@@ -294,7 +268,7 @@ describe('read', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
});
|
||||
|
||||
@@ -350,7 +324,7 @@ describe('write', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
});
|
||||
|
||||
@@ -421,7 +395,7 @@ describe('readFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
|
||||
saga.put(fileStorageReadFile('test.file'));
|
||||
@@ -488,7 +462,7 @@ describe('writeFile', () => {
|
||||
const contents = 'test write file contents';
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
|
||||
saga.put(fileStorageWriteFile('test.file', contents));
|
||||
@@ -553,13 +527,12 @@ describe('writeFile', () => {
|
||||
|
||||
describe('copyFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
let testFile: FileMetadata;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
[testFile] = await setUpTestFile(saga);
|
||||
await setUpTestFile(saga);
|
||||
});
|
||||
|
||||
it('should fail if file does not exist', async () => {
|
||||
@@ -612,10 +585,6 @@ describe('copyFile', () => {
|
||||
saga.put(fileStorageCopyFile('test.file', 'new.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidCopyFile('test.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidAddItem({ ...testFile, uuid: uuid(1), path: 'new.file' }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -625,13 +594,12 @@ describe('copyFile', () => {
|
||||
|
||||
describe('deleteFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
let testFile: FileMetadata;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
[testFile] = await setUpTestFile(saga);
|
||||
await setUpTestFile(saga);
|
||||
});
|
||||
|
||||
it('should fail if file does not exist', async () => {
|
||||
@@ -667,8 +635,6 @@ describe('deleteFile', () => {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidDeleteFile('test.file'),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidRemoveItem(testFile));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -682,7 +648,7 @@ describe('renameFile', () => {
|
||||
const newPath = 'new.file';
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
[testFile] = await setUpTestFile(saga);
|
||||
@@ -724,13 +690,6 @@ describe('renameFile', () => {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen(newPath, 1 as FD),
|
||||
);
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidAddItem({
|
||||
uuid: uuid(1),
|
||||
path: newPath,
|
||||
sha256: emptyFileSha256,
|
||||
}),
|
||||
);
|
||||
|
||||
saga.put(fileStorageRenameFile('test.file', newPath));
|
||||
|
||||
@@ -760,15 +719,9 @@ describe('renameFile', () => {
|
||||
it('should change file', async () => {
|
||||
saga.put(fileStorageRenameFile(testFile.path, newPath));
|
||||
|
||||
const newMetadata: FileMetadata = { ...testFile, path: newPath };
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidRenameFile(testFile.path),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidChangeItem(testFile, newMetadata),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -782,7 +735,7 @@ describe('dump all files', () => {
|
||||
let testFileContents: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
[testFile, testFileContents] = await setUpTestFile(saga);
|
||||
|
||||
+11
-124
@@ -1,29 +1,24 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import Dexie, { Table } from 'dexie';
|
||||
import {
|
||||
ICreateChange,
|
||||
IDatabaseChange,
|
||||
IDeleteChange,
|
||||
IUpdateChange,
|
||||
} from 'dexie-observable/api';
|
||||
import 'dexie-observable';
|
||||
import { eventChannel } from 'redux-saga';
|
||||
import { call, fork, put, race, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
call,
|
||||
fork,
|
||||
getContext,
|
||||
put,
|
||||
race,
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'typed-redux-saga/macro';
|
||||
import { defined, ensureError } from '../utils';
|
||||
import { sha256Digest } from '../utils/crypto';
|
||||
import { createCountFunc } from '../utils/iter';
|
||||
import {
|
||||
FD,
|
||||
FileMetadata,
|
||||
FileOpenMode,
|
||||
UUID,
|
||||
fileStorageClose,
|
||||
fileStorageCopyFile,
|
||||
fileStorageDeleteFile,
|
||||
fileStorageDidAddItem,
|
||||
fileStorageDidChangeItem,
|
||||
fileStorageDidClose,
|
||||
fileStorageDidCopyFile,
|
||||
fileStorageDidDeleteFile,
|
||||
@@ -42,7 +37,6 @@ import {
|
||||
fileStorageDidOpen,
|
||||
fileStorageDidRead,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRemoveItem,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWrite,
|
||||
fileStorageDidWriteFile,
|
||||
@@ -54,88 +48,13 @@ import {
|
||||
fileStorageWrite,
|
||||
fileStorageWriteFile,
|
||||
} from './actions';
|
||||
import { FileMetadata, FileStorageDb, UUID } from '.';
|
||||
|
||||
// HACK: we have to redefine DatabaseChangeType since it is a const enum
|
||||
// https://ncjamieson.com/dont-export-const-enums
|
||||
const enum DatabaseChangeType {
|
||||
Create = 1,
|
||||
Update = 2,
|
||||
Delete = 3,
|
||||
}
|
||||
|
||||
/** Type discriminator for {@link ICreateChange} */
|
||||
function isCreateChange(change: IDatabaseChange): change is ICreateChange {
|
||||
return change.type === Number(DatabaseChangeType.Create);
|
||||
}
|
||||
|
||||
/** Type discriminator for {@link IUpdateChange} */
|
||||
function isUpdateChange(change: IDatabaseChange): change is IUpdateChange {
|
||||
return change.type === Number(DatabaseChangeType.Update);
|
||||
}
|
||||
|
||||
/** Type discriminator for {@link IDeleteChange} */
|
||||
function isDeleteChange(change: IDatabaseChange): change is IDeleteChange {
|
||||
return change.type === Number(DatabaseChangeType.Delete);
|
||||
}
|
||||
|
||||
/** Type discriminator for {@link ICreateChange} of {@link FileMetadata} */
|
||||
function isFileMetadataCreateChange(
|
||||
change: ICreateChange,
|
||||
): change is Omit<ICreateChange, 'key' | 'obj'> & { key: string; obj: FileMetadata } {
|
||||
return change.table === 'metadata';
|
||||
}
|
||||
|
||||
/** Type discriminator for {@link IUpdateChange} of {@link FileMetadata} */
|
||||
function isFileMetadataUpdateChange(change: IUpdateChange): change is Omit<
|
||||
IUpdateChange,
|
||||
'key' | 'obj' | 'oldObj'
|
||||
> & {
|
||||
key: string;
|
||||
obj: FileMetadata;
|
||||
oldObj: FileMetadata;
|
||||
} {
|
||||
return change.table === 'metadata';
|
||||
}
|
||||
|
||||
/** Type discriminator for {@link IDeleteChange} of {@link FileMetadata} */
|
||||
function isFileMetaDataDeleteChange(change: IDeleteChange): change is Omit<
|
||||
IDeleteChange,
|
||||
'key' | 'oldObj'
|
||||
> & {
|
||||
key: string;
|
||||
oldObj: FileMetadata;
|
||||
} {
|
||||
return change.table === 'metadata';
|
||||
}
|
||||
|
||||
/** Database contents table data type. */
|
||||
type FileContents = {
|
||||
/** The path of the file in storage. */
|
||||
path: string;
|
||||
/** The contents of the file. */
|
||||
contents: string;
|
||||
};
|
||||
export type FileStorageSageContext = { fileStorage: FileStorageDb };
|
||||
|
||||
/** Map for keeping track of open file descriptors. */
|
||||
type OpenFdMap = Map<FD, { mode: FileOpenMode; uuid: UUID }>;
|
||||
|
||||
class FileStorageDb extends Dexie {
|
||||
metadata!: Table<FileMetadata, UUID>;
|
||||
// NB: This table starts with an underscore to hide it from Dexie observable.
|
||||
// In the future we may change this to use File Access API or some other
|
||||
// storage, so we don't want to rely on the file contents being included
|
||||
// with the metadata.
|
||||
_contents!: Table<FileContents, string>;
|
||||
|
||||
constructor() {
|
||||
super('pybricks.fileStorage');
|
||||
this.version(1).stores({
|
||||
metadata: '$$uuid, &path, sha256',
|
||||
_contents: 'path, contents',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a namespaced lock name for the given path.
|
||||
*/
|
||||
@@ -143,28 +62,6 @@ function lockNameForPath(path: string): string {
|
||||
return `pybricks.fileStorage:${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts localForage change events to redux actions.
|
||||
* @param changes The list of changes from the 'changed' event.
|
||||
*/
|
||||
function* handleFileStorageDidChange(changes: IDatabaseChange[]): Generator {
|
||||
for (const change of changes) {
|
||||
if (isCreateChange(change)) {
|
||||
if (isFileMetadataCreateChange(change)) {
|
||||
yield* put(fileStorageDidAddItem(change.obj));
|
||||
}
|
||||
} else if (isUpdateChange(change)) {
|
||||
if (isFileMetadataUpdateChange(change)) {
|
||||
yield* put(fileStorageDidChangeItem(change.oldObj, change.obj));
|
||||
}
|
||||
} else if (isDeleteChange(change)) {
|
||||
if (isFileMetaDataDeleteChange(change)) {
|
||||
yield* put(fileStorageDidRemoveItem(change.oldObj));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles requests to open a file.
|
||||
* @param db The database instance.
|
||||
@@ -706,7 +603,7 @@ function* initialize(): Generator {
|
||||
const defer = new Array<(...args: unknown[]) => unknown>();
|
||||
|
||||
try {
|
||||
const db = new FileStorageDb();
|
||||
const db = yield* getContext<FileStorageDb>('fileStorage');
|
||||
|
||||
// migrate from old storage
|
||||
|
||||
@@ -735,21 +632,11 @@ function* initialize(): Generator {
|
||||
yield* call(() => db.open());
|
||||
defer.push(() => db.close());
|
||||
|
||||
// wire storage observable to redux-sagas
|
||||
|
||||
const changesChan = eventChannel<IDatabaseChange[]>((emit) => {
|
||||
db.on('changes').subscribe(emit);
|
||||
return () => db.on('changes').unsubscribe(emit);
|
||||
});
|
||||
|
||||
defer.push(() => changesChan.close());
|
||||
|
||||
// subscribe to events
|
||||
|
||||
const nextFd = createCountFunc() as () => FD;
|
||||
const openFds: OpenFdMap = new Map();
|
||||
|
||||
yield* takeEvery(changesChan, handleFileStorageDidChange);
|
||||
yield* takeEvery(fileStorageOpen, handleOpen, db, nextFd, openFds);
|
||||
yield* takeEvery(fileStorageClose, handleClose, openFds);
|
||||
yield* takeEvery(fileStorageRead, handleRead, db, openFds);
|
||||
|
||||
@@ -12,6 +12,7 @@ import createSagaMiddleware from 'redux-saga';
|
||||
import './index.scss';
|
||||
import App from './app/App';
|
||||
import { appVersion } from './app/constants';
|
||||
import { db } from './fileStorage/context';
|
||||
import { i18nManager } from './i18n';
|
||||
import * as I18nToaster from './notifications/I18nToaster';
|
||||
import { rootReducer } from './reducers';
|
||||
@@ -28,6 +29,7 @@ const sagaMiddleware = createSagaMiddleware<RootSagaContext>({
|
||||
nextMessageId: createCountFunc(),
|
||||
notification: { toaster },
|
||||
terminal: defaultTerminalContext,
|
||||
fileStorage: db,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
-2
@@ -9,7 +9,7 @@ import ble from './ble/sagas';
|
||||
import editor from './editor/sagas';
|
||||
import errorLog from './error-log/sagas';
|
||||
import explorer from './explorer/sagas';
|
||||
import fileStorage from './fileStorage/sagas';
|
||||
import fileStorage, { FileStorageSageContext } from './fileStorage/sagas';
|
||||
import flashFirmware from './firmware/sagas';
|
||||
import hub from './hub/sagas';
|
||||
import lwp3BootloaderProtocol from './lwp3-bootloader/sagas';
|
||||
@@ -44,5 +44,6 @@ export default function* (): Generator {
|
||||
*/
|
||||
export type RootSagaContext = {
|
||||
nextMessageId: () => number;
|
||||
} & NotificationSagaContext &
|
||||
} & FileStorageSageContext &
|
||||
NotificationSagaContext &
|
||||
TerminalSagaContext;
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
// @ts-expect-error no typings
|
||||
import matchMediaPolyfill from 'mq-polyfill';
|
||||
|
||||
jest.mock('./fileStorage/hooks');
|
||||
|
||||
//https://stackoverflow.com/a/66515427/1976323
|
||||
matchMediaPolyfill(window);
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import React, { ReactElement } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { AnyAction, DeepPartial, PreloadedState, createStore } from 'redux';
|
||||
import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga';
|
||||
import { UUID } from '../src/fileStorage/actions';
|
||||
import { UUID } from '../src/fileStorage';
|
||||
import { RootState, rootReducer } from '../src/reducers';
|
||||
import { RootSagaContext } from '../src/sagas';
|
||||
|
||||
|
||||
@@ -2590,6 +2590,7 @@ __metadata:
|
||||
css-minimizer-webpack-plugin: ^3.2.0
|
||||
dexie: ^3.2.1
|
||||
dexie-observable: ^4.0.0-beta.13
|
||||
dexie-react-hooks: ^1.1.1
|
||||
dotenv: ^10.0.0
|
||||
dotenv-expand: ^5.1.0
|
||||
eslint: ^7.31.0
|
||||
@@ -7353,6 +7354,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dexie-react-hooks@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "dexie-react-hooks@npm:1.1.1"
|
||||
peerDependencies:
|
||||
"@types/react": ">=16"
|
||||
dexie: ">=3.1.0-alpha.1 <5.0.0"
|
||||
react: ">=16"
|
||||
checksum: 09e1b1d4ebc248d103eb13c8f77733f4c140478350d2b8f3989af2fedb45e0d4ae7a07df9281ade162a3264ff621db35899b47d6edf5bf532ba22d571f6f1496
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dexie@npm:^3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "dexie@npm:3.2.1"
|
||||
|
||||
Reference in New Issue
Block a user