mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
editor: handle files by uuid
This changes things up a bit so that files are handled by uuid instead of path. The allows for files to be renamed without breaking things. Also some improvements with persistence of the view state for each file is made.
This commit is contained in:
@@ -6,28 +6,43 @@ import { RenderResult, cleanup, fireEvent, waitFor } from '@testing-library/reac
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { monaco } from 'react-monaco-editor';
|
||||
import { testRender } from '../../test';
|
||||
import { testRender, uuid } from '../../test';
|
||||
import { FileMetadata } from '../fileStorage';
|
||||
import { useFileStorageMetadata, useFileStoragePath } from '../fileStorage/hooks';
|
||||
import { defined } from '../utils';
|
||||
import Editor from './Editor';
|
||||
import { editorActivateFile, editorCloseFile } from './actions';
|
||||
|
||||
const testFile: FileMetadata = {
|
||||
uuid: uuid(0),
|
||||
path: 'test.file',
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
};
|
||||
|
||||
describe('Editor', () => {
|
||||
describe('tabs', () => {
|
||||
it('should dispatch activate action when tab is clicked', async () => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
jest.mocked(useFileStoragePath).mockReturnValue(testFile.path);
|
||||
|
||||
const [editor, dispatch] = testRender(<Editor />, {
|
||||
editor: { openFiles: ['test.file'] },
|
||||
editor: { openFileUuids: [testFile.uuid] },
|
||||
});
|
||||
|
||||
userEvent.click(editor.getByRole('tab', { name: 'test.file' }));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(editorActivateFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(editorActivateFile(testFile.uuid));
|
||||
});
|
||||
|
||||
it.each(['enter', 'space'])(
|
||||
'should dispatch activate action when % button is pressed',
|
||||
async (button) => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
jest.mocked(useFileStoragePath).mockReturnValue(testFile.path);
|
||||
|
||||
const [editor, dispatch] = testRender(<Editor />, {
|
||||
editor: { openFiles: ['test.file'] },
|
||||
editor: { openFileUuids: [testFile.uuid] },
|
||||
});
|
||||
|
||||
userEvent.type(
|
||||
@@ -35,28 +50,36 @@ describe('Editor', () => {
|
||||
`{${button}}`,
|
||||
);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(editorActivateFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
editorActivateFile(testFile.uuid),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should dispatch close action when close button is clicked', async () => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
jest.mocked(useFileStoragePath).mockReturnValue(testFile.path);
|
||||
|
||||
const [editor, dispatch] = testRender(<Editor />, {
|
||||
editor: { openFiles: ['test.file'] },
|
||||
editor: { openFileUuids: [testFile.uuid] },
|
||||
});
|
||||
|
||||
userEvent.click(editor.getByRole('button', { name: 'Close test.file' }));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(editorCloseFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(editorCloseFile(testFile.uuid));
|
||||
});
|
||||
|
||||
it('should dispatch close action when delete button is pressed', async () => {
|
||||
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
|
||||
jest.mocked(useFileStoragePath).mockReturnValue(testFile.path);
|
||||
|
||||
const [editor, dispatch] = testRender(<Editor />, {
|
||||
editor: { openFiles: ['test.file'] },
|
||||
editor: { openFileUuids: [testFile.uuid] },
|
||||
});
|
||||
|
||||
userEvent.type(editor.getByRole('tab', { name: 'test.file' }), '{delete}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(editorCloseFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(editorCloseFile(testFile.uuid));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+86
-30
@@ -18,7 +18,7 @@ import {
|
||||
import { ContextMenu2, ResizeSensor2 } from '@blueprintjs/popover2';
|
||||
import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json';
|
||||
import xcodeTheme from 'monaco-themes/themes/Xcode_default.json';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useId } from 'react-aria';
|
||||
import MonacoEditor, {
|
||||
EditorDidMount,
|
||||
@@ -28,6 +28,8 @@ import MonacoEditor, {
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTernaryDarkMode } from 'usehooks-ts';
|
||||
import { IDisposable } from 'xterm';
|
||||
import { UUID } from '../fileStorage';
|
||||
import { useFileStoragePath } from '../fileStorage/hooks';
|
||||
import { compile } from '../mpy/actions';
|
||||
import { useSelector } from '../reducers';
|
||||
import { useSettingIsShowDocsEnabled } from '../settings/hooks';
|
||||
@@ -173,20 +175,77 @@ const EditorContextMenu: React.VoidFunctionComponent<EditorContextMenuProps> = (
|
||||
);
|
||||
};
|
||||
|
||||
type FileNameProps = {
|
||||
/** The DOM ID. */
|
||||
id: string;
|
||||
/** The file UUID. */
|
||||
uuid: UUID;
|
||||
/** Called when the file name changes. */
|
||||
onNameChanged: () => void;
|
||||
};
|
||||
|
||||
const TabLabel: React.VoidFunctionComponent<FileNameProps> = ({
|
||||
id,
|
||||
uuid,
|
||||
onNameChanged,
|
||||
}) => {
|
||||
const fileName = useFileStoragePath(uuid);
|
||||
|
||||
useEffect(() => {
|
||||
onNameChanged?.();
|
||||
}, [fileName, onNameChanged]);
|
||||
|
||||
return (
|
||||
<Text tagName="span" id={id} ellipsize={true}>
|
||||
{fileName}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
type TabCloseButtonProps = {
|
||||
/** The file UUID. */
|
||||
uuid: UUID;
|
||||
};
|
||||
|
||||
const TabCloseButton: React.VoidFunctionComponent<TabCloseButtonProps> = ({ uuid }) => {
|
||||
const fileName = useFileStoragePath(uuid) ?? '';
|
||||
const dispatch = useDispatch();
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={i18n.translate(I18nId.CloseFileTooltip, {
|
||||
fileName,
|
||||
})}
|
||||
minimal={true}
|
||||
small={true}
|
||||
icon={'cross'}
|
||||
// tabs are closed with delete button by keyboard, so
|
||||
// don't focus the close button
|
||||
tabIndex={-1}
|
||||
onFocus={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
dispatch(editorCloseFile(uuid));
|
||||
// prevent triggering Tabs onChange
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type EditorTabsProps = Readonly<{
|
||||
/** Called when the selected tab changes. */
|
||||
onChange?: () => void;
|
||||
}>;
|
||||
|
||||
const EditorTabs: React.VoidFunctionComponent<EditorTabsProps> = ({ onChange }) => {
|
||||
const openFiles = useSelector((s) => s.editor.openFiles);
|
||||
const activeFile = useSelector((s) => s.editor.activeFile);
|
||||
const openFiles = useSelector((s) => s.editor.openFileUuids);
|
||||
const activeFile = useSelector((s) => s.editor.activeFileUuid);
|
||||
const dispatch = useDispatch();
|
||||
const i18n = useI18n();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newTabId: TabId) => {
|
||||
dispatch(editorActivateFile(newTabId as string));
|
||||
dispatch(editorActivateFile(newTabId as UUID));
|
||||
onChange?.();
|
||||
},
|
||||
[dispatch, onChange],
|
||||
@@ -196,9 +255,9 @@ const EditorTabs: React.VoidFunctionComponent<EditorTabsProps> = ({ onChange })
|
||||
|
||||
// close tab when delete key is pressed
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, fileName: string) => {
|
||||
(e: React.KeyboardEvent, uuid: UUID) => {
|
||||
if (e.key === 'Delete') {
|
||||
dispatch(editorCloseFile(fileName));
|
||||
dispatch(editorCloseFile(uuid));
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
@@ -206,38 +265,35 @@ const EditorTabs: React.VoidFunctionComponent<EditorTabsProps> = ({ onChange })
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const tabsRef = useRef<Tabs>(null);
|
||||
|
||||
// HACK: call private Tabs method to fix selection indicator animation when
|
||||
// a file is renamed
|
||||
const handleNameChanged = useCallback(() => {
|
||||
tabsRef.current?.['moveSelectionIndicator']();
|
||||
}, [tabsRef]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className="pb-editor-tablist"
|
||||
selectedTabId={activeFile}
|
||||
selectedTabId={activeFile || undefined}
|
||||
ref={tabsRef}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{openFiles.map((fileName, i) => (
|
||||
{openFiles.map((uuid) => (
|
||||
<Tab
|
||||
className="pb-editor-tablist-tab"
|
||||
aria-labelledby={`${labelId}.${i}`}
|
||||
key={i}
|
||||
id={fileName}
|
||||
onKeyDown={(e) => handleKeyDown(e, fileName)}
|
||||
aria-labelledby={`${labelId}.${uuid}`}
|
||||
key={uuid}
|
||||
id={uuid}
|
||||
onKeyDown={(e) => handleKeyDown(e, uuid)}
|
||||
>
|
||||
<Text tagName="span" id={`${labelId}.${i}`} ellipsize={true}>
|
||||
{fileName}
|
||||
</Text>
|
||||
<Button
|
||||
title={i18n.translate(I18nId.CloseFileTooltip, { fileName })}
|
||||
minimal={true}
|
||||
small={true}
|
||||
icon={'cross'}
|
||||
// tabs are closed with delete button by keyboard, so
|
||||
// don't focus the close button
|
||||
tabIndex={-1}
|
||||
onFocus={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
dispatch(editorCloseFile(fileName));
|
||||
// prevent triggering Tabs onChange
|
||||
e.stopPropagation();
|
||||
}}
|
||||
<TabLabel
|
||||
id={`${labelId}.${uuid}`}
|
||||
uuid={uuid}
|
||||
onNameChanged={handleNameChanged}
|
||||
/>
|
||||
<TabCloseButton uuid={uuid} />
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
+31
-34
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createAction } from '../actions';
|
||||
import { UUID } from '../fileStorage';
|
||||
|
||||
/** Action that indicates that a code editor was created. */
|
||||
export const editorDidCreate = createAction(() => ({
|
||||
@@ -30,42 +31,40 @@ export const editorGetValueResponse = createAction((id: number, value: string) =
|
||||
|
||||
/**
|
||||
* Requests to open a file in the editor.
|
||||
* @param fileName the file name.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
export const editorOpenFile = createAction((fileName: string) => ({
|
||||
export const editorOpenFile = createAction((uuid: UUID) => ({
|
||||
type: 'editor.action.openFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link editorOpenFile} succeeded.
|
||||
* @param fileName the file name.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
export const editorDidOpenFile = createAction((fileName: string) => ({
|
||||
export const editorDidOpenFile = createAction((uuid: UUID) => ({
|
||||
type: 'editor.action.didOpenFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link editorOpenFile} failed.
|
||||
* @param fileName the file name.
|
||||
* @param uuid The file UUID.
|
||||
* @param error the error.
|
||||
*/
|
||||
export const editorDidFailToOpenFile = createAction(
|
||||
(fileName: string, error: Error) => ({
|
||||
type: 'editor.action.didFailToOpenFile',
|
||||
fileName,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
export const editorDidFailToOpenFile = createAction((uuid: UUID, error: Error) => ({
|
||||
type: 'editor.action.didFailToOpenFile',
|
||||
uuid,
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Requests to close a file in the editor.
|
||||
* @param fileName the file name.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
export const editorCloseFile = createAction((fileName: string) => ({
|
||||
export const editorCloseFile = createAction((uuid: UUID) => ({
|
||||
type: 'editor.action.closeFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -73,40 +72,38 @@ export const editorCloseFile = createAction((fileName: string) => ({
|
||||
*
|
||||
* Unlike most actions, this does not have a "did fail" counterpart.
|
||||
*
|
||||
* @param fileName the file name.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
export const editorDidCloseFile = createAction((fileName: string) => ({
|
||||
export const editorDidCloseFile = createAction((uuid: UUID) => ({
|
||||
type: 'editor.action.didCloseFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Request to activate a file (open or bring to foreground if already open).
|
||||
* @param fileName The file name.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
export const editorActivateFile = createAction((fileName: string) => ({
|
||||
export const editorActivateFile = createAction((uuid: UUID) => ({
|
||||
type: 'editor.action.activateFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link editorActivateFile} succeeded.
|
||||
* @param fileName The file name.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
export const editorDidActivateFile = createAction((fileName: string) => ({
|
||||
export const editorDidActivateFile = createAction((uuid: UUID) => ({
|
||||
type: 'editor.action.didActivateFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link editorActivateFile} failed.
|
||||
* @param fileName The file name.
|
||||
* @param uuid The file UUID.
|
||||
* @param error The error that was raised.
|
||||
*/
|
||||
export const editorDidFailToActivateFile = createAction(
|
||||
(fileName: string, error: Error) => ({
|
||||
type: 'editor.action.didFailToActivateFile',
|
||||
fileName,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
export const editorDidFailToActivateFile = createAction((uuid: UUID, error: Error) => ({
|
||||
type: 'editor.action.didFailToActivateFile',
|
||||
uuid,
|
||||
error,
|
||||
}));
|
||||
|
||||
+41
-35
@@ -2,9 +2,15 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { monaco } from 'react-monaco-editor';
|
||||
import type { monaco } from 'react-monaco-editor';
|
||||
import { uuid } from '../../test';
|
||||
import { ActiveFileHistoryManager, OpenFileInfo, OpenFileManager } from './lib';
|
||||
|
||||
const testFileUuid = uuid(0);
|
||||
const oneFileUuid = uuid(1);
|
||||
const twoFileUuid = uuid(2);
|
||||
const threeFileUuid = uuid(3);
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
@@ -18,73 +24,73 @@ describe('ActiveFileHistoryManager', () => {
|
||||
it('should return history from sessionStorage', () => {
|
||||
sessionStorage.setItem(
|
||||
`editor.activeFileHistory.${window.name}.test`,
|
||||
'["one.file","two.file"]',
|
||||
`["${oneFileUuid}","${twoFileUuid}"]`,
|
||||
);
|
||||
|
||||
const manager = new ActiveFileHistoryManager('test');
|
||||
expect([...manager.getFromStorage()]).toEqual(['one.file', 'two.file']);
|
||||
expect([...manager.getFromStorage()]).toEqual([oneFileUuid, twoFileUuid]);
|
||||
});
|
||||
|
||||
it('should save to sessionStorage', () => {
|
||||
const manager = new ActiveFileHistoryManager('test');
|
||||
|
||||
manager.push('one.file');
|
||||
manager.push('two.file');
|
||||
manager.push(oneFileUuid);
|
||||
manager.push(twoFileUuid);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
|
||||
).toEqual('["one.file","two.file"]');
|
||||
).toEqual(`["${oneFileUuid}","${twoFileUuid}"]`);
|
||||
});
|
||||
|
||||
it('should reorder existing files', () => {
|
||||
const manager = new ActiveFileHistoryManager('test');
|
||||
|
||||
manager.push('one.file');
|
||||
manager.push('two.file');
|
||||
manager.push('one.file');
|
||||
manager.push(oneFileUuid);
|
||||
manager.push(twoFileUuid);
|
||||
manager.push(oneFileUuid);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
|
||||
).toEqual('["two.file","one.file"]');
|
||||
).toEqual(`["${twoFileUuid}","${oneFileUuid}"]`);
|
||||
});
|
||||
|
||||
it('should known when active file was popped', () => {
|
||||
const manager = new ActiveFileHistoryManager('test');
|
||||
|
||||
manager.push('one.file');
|
||||
manager.push('two.file');
|
||||
manager.push(oneFileUuid);
|
||||
manager.push(twoFileUuid);
|
||||
|
||||
expect(manager.pop('two.file')).toBe('one.file');
|
||||
expect(manager.pop(twoFileUuid)).toBe(oneFileUuid);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
|
||||
).toEqual('["one.file"]');
|
||||
).toEqual(`["${oneFileUuid}"]`);
|
||||
});
|
||||
|
||||
it('should known when not active file was popped', () => {
|
||||
const manager = new ActiveFileHistoryManager('test');
|
||||
|
||||
manager.push('one.file');
|
||||
manager.push('two.file');
|
||||
manager.push(oneFileUuid);
|
||||
manager.push(twoFileUuid);
|
||||
|
||||
expect(manager.pop('one.file')).toBe(undefined);
|
||||
expect(manager.pop(oneFileUuid)).toBe(undefined);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
|
||||
).toEqual('["two.file"]');
|
||||
).toEqual(`["${twoFileUuid}"]`);
|
||||
});
|
||||
|
||||
it('should known when never active file was popped', () => {
|
||||
const manager = new ActiveFileHistoryManager('test');
|
||||
|
||||
manager.push('one.file');
|
||||
manager.push('two.file');
|
||||
manager.push(oneFileUuid);
|
||||
manager.push(twoFileUuid);
|
||||
|
||||
expect(manager.pop('three.file')).toBe(undefined);
|
||||
expect(manager.pop(threeFileUuid)).toBe(undefined);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
|
||||
).toEqual('["one.file","two.file"]');
|
||||
).toEqual(`["${oneFileUuid}","${twoFileUuid}"]`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,40 +98,40 @@ describe('OpenFileManager', () => {
|
||||
it('should add and remove files', () => {
|
||||
const manager = new OpenFileManager();
|
||||
|
||||
expect(manager.has('test.file')).toBeFalsy();
|
||||
expect(manager.get('test.file')).toBeUndefined();
|
||||
expect(manager.has(testFileUuid)).toBeFalsy();
|
||||
expect(manager.get(testFileUuid)).toBeUndefined();
|
||||
|
||||
const model = mock<monaco.editor.ITextModel>();
|
||||
|
||||
manager.add('test.file', model, null);
|
||||
manager.add(testFileUuid, model, null);
|
||||
|
||||
expect(manager.has('test.file')).toBeTruthy();
|
||||
expect(manager.get('test.file')).toEqual(<OpenFileInfo>{
|
||||
expect(manager.has(testFileUuid)).toBeTruthy();
|
||||
expect(manager.get(testFileUuid)).toEqual(<OpenFileInfo>{
|
||||
model,
|
||||
viewState: null,
|
||||
});
|
||||
|
||||
manager.remove('test.file');
|
||||
manager.remove(testFileUuid);
|
||||
|
||||
expect(manager.has('test.file')).toBeFalsy();
|
||||
expect(manager.get('test.file')).toBeUndefined();
|
||||
expect(manager.has(testFileUuid)).toBeFalsy();
|
||||
expect(manager.get(testFileUuid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update viewState', () => {
|
||||
const manager = new OpenFileManager();
|
||||
|
||||
// does not fail if key does not exist
|
||||
manager.updateViewState('test.file', null);
|
||||
manager.updateViewState(testFileUuid, null);
|
||||
|
||||
const model = mock<monaco.editor.ITextModel>();
|
||||
const viewState = mock<monaco.editor.ICodeEditorViewState>();
|
||||
|
||||
manager.add('test.file', model, viewState);
|
||||
manager.add(testFileUuid, model, viewState);
|
||||
|
||||
expect(manager.get('test.file')).toHaveProperty('viewState', viewState);
|
||||
expect(manager.get(testFileUuid)).toHaveProperty('viewState', viewState);
|
||||
|
||||
manager.updateViewState('test.file', null);
|
||||
manager.updateViewState(testFileUuid, null);
|
||||
|
||||
expect(manager.get('test.file')).toHaveProperty('viewState', null);
|
||||
expect(manager.get(testFileUuid)).toHaveProperty('viewState', null);
|
||||
});
|
||||
});
|
||||
|
||||
+56
-35
@@ -2,7 +2,8 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import dexieObservable from 'dexie-observable';
|
||||
import { monaco } from 'react-monaco-editor';
|
||||
import type { monaco } from 'react-monaco-editor';
|
||||
import { UUID } from '../fileStorage';
|
||||
|
||||
// HACK: Using window.name to detect page reloads vs. tab duplication.
|
||||
// window.name will persist across page reloads but will be set back to ''
|
||||
@@ -24,7 +25,7 @@ if (window.name === '') {
|
||||
* duplicated windows.
|
||||
*/
|
||||
export class ActiveFileHistoryManager {
|
||||
private readonly history = new Array<string>();
|
||||
private readonly history = new Array<UUID>();
|
||||
private readonly storageKey: string;
|
||||
|
||||
public constructor(id: string) {
|
||||
@@ -43,7 +44,7 @@ export class ActiveFileHistoryManager {
|
||||
* {@link ActiveFileHistoryManager} has been created to get the old values
|
||||
* from the previous window reload.
|
||||
*/
|
||||
public *getFromStorage(): IterableIterator<string> {
|
||||
public *getFromStorage(): IterableIterator<UUID> {
|
||||
try {
|
||||
const savedActiveFileHistory = JSON.parse(
|
||||
sessionStorage.getItem(this.storageKey) || '[]',
|
||||
@@ -66,7 +67,7 @@ export class ActiveFileHistoryManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield item;
|
||||
yield <UUID>item;
|
||||
}
|
||||
} catch (err) {
|
||||
// istanbul ignore next: not a critical error
|
||||
@@ -77,16 +78,16 @@ export class ActiveFileHistoryManager {
|
||||
/**
|
||||
* Pushes a file on top of the stack. If the file was already in the stack,
|
||||
* it is moved to the top.
|
||||
* @param fileName: the file name
|
||||
* @param uuid: The file UUID.
|
||||
*/
|
||||
public push(fileName: string): void {
|
||||
const index = this.history.indexOf(fileName);
|
||||
public push(uuid: UUID): void {
|
||||
const index = this.history.indexOf(uuid);
|
||||
|
||||
if (index >= 0) {
|
||||
this.history.splice(index, 1);
|
||||
}
|
||||
|
||||
this.history.push(fileName);
|
||||
this.history.push(uuid);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify(this.history));
|
||||
@@ -100,20 +101,20 @@ export class ActiveFileHistoryManager {
|
||||
* Pops an item from the list.
|
||||
*
|
||||
* The item is not necessarily the (top) active item.
|
||||
* @param fileName The file to remove.
|
||||
* @returns The new active file if {@link fileName} was the active file or
|
||||
* undefined if {@link fileName} was not the active file (or not in the
|
||||
* @param uuid The UUID of the file to remove.
|
||||
* @returns The new active file if {@link uuid} was the active file or
|
||||
* undefined if {@link uuid} was not the active file (or not in the
|
||||
* the history at all.)
|
||||
*/
|
||||
public pop(fileName: string): string | undefined {
|
||||
const index = this.history.indexOf(fileName);
|
||||
public pop(uuid: UUID): UUID | undefined {
|
||||
const index = this.history.indexOf(uuid);
|
||||
|
||||
if (index < 0) {
|
||||
// the file is not in history
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const wasActiveFile = this.history.at(-1) === fileName;
|
||||
const wasActiveFile = this.history.at(-1) === uuid;
|
||||
this.history.splice(index, 1);
|
||||
|
||||
try {
|
||||
@@ -135,44 +136,64 @@ export type OpenFileInfo = {
|
||||
};
|
||||
|
||||
export class OpenFileManager {
|
||||
private readonly map: Map<string, OpenFileInfo> = new Map();
|
||||
private readonly map: Map<UUID, OpenFileInfo> = new Map();
|
||||
|
||||
/**
|
||||
* Adds a new file to the list of open files.
|
||||
* @param uuid The file UUID.
|
||||
* @param model The text editor model.
|
||||
* @param viewState The text editor view state.
|
||||
*/
|
||||
public add(
|
||||
fileName: string,
|
||||
uuid: UUID,
|
||||
model: monaco.editor.ITextModel,
|
||||
viewState: monaco.editor.ICodeEditorViewState | null,
|
||||
): void {
|
||||
// istanbul ignore if: bug if hit
|
||||
if (this.map.has(fileName)) {
|
||||
throw new Error(`bug: key '${fileName}' already exists in the mpa`);
|
||||
if (this.map.has(uuid)) {
|
||||
throw new Error(`bug: key '${uuid}' already exists in the map`);
|
||||
}
|
||||
|
||||
this.map.set(fileName, { model, viewState });
|
||||
}
|
||||
|
||||
public remove(fileName: string): void {
|
||||
this.map.delete(fileName);
|
||||
}
|
||||
|
||||
public has(fileName: string): boolean {
|
||||
return this.map.has(fileName);
|
||||
}
|
||||
|
||||
public get(fileName: string): OpenFileInfo | undefined {
|
||||
return this.map.get(fileName);
|
||||
this.map.set(uuid, { model, viewState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the view state of {@link fileName} if it is present, otherwise
|
||||
* Removes a file from the list of open files.
|
||||
* @param uuid The file UUID.
|
||||
*/
|
||||
public remove(uuid: UUID): void {
|
||||
this.map.delete(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if {@link uuid} is in the list of open files.
|
||||
* @param uuid The file UUID.
|
||||
* @returns `true` if the file is already open, otherwise `false`.
|
||||
*/
|
||||
public has(uuid: UUID): boolean {
|
||||
return this.map.has(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the info for {@link uuid}.
|
||||
* @param uuid The file UUID.
|
||||
* @returns The file info or `undefined` if the file is not in the list.
|
||||
*/
|
||||
public get(uuid: UUID): OpenFileInfo | undefined {
|
||||
return this.map.get(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the view state of {@link uuid} if it is present, otherwise
|
||||
* does nothing.
|
||||
* @param fileName The lookup key.
|
||||
* @param uuid The lookup key.
|
||||
* @param viewState The new view state.
|
||||
*/
|
||||
public updateViewState(
|
||||
fileName: string,
|
||||
uuid: UUID,
|
||||
viewState: monaco.editor.ICodeEditorViewState | null,
|
||||
): void {
|
||||
const info = this.map.get(fileName);
|
||||
const info = this.map.get(uuid);
|
||||
|
||||
if (!info) {
|
||||
return;
|
||||
|
||||
+13
-10
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { uuid } from '../../test';
|
||||
import {
|
||||
editorDidActivateFile,
|
||||
editorDidCloseFile,
|
||||
@@ -11,6 +12,8 @@ import reducers from './reducers';
|
||||
|
||||
type State = ReturnType<typeof reducers>;
|
||||
|
||||
const testUuid = uuid(0);
|
||||
|
||||
describe('isReady', () => {
|
||||
it('should change state when editor is created', () => {
|
||||
expect(
|
||||
@@ -22,9 +25,9 @@ describe('isReady', () => {
|
||||
describe('activeFile', () => {
|
||||
it('should change state when a file is activated', () => {
|
||||
expect(
|
||||
reducers({ activeFile: '' } as State, editorDidActivateFile('test.file'))
|
||||
.activeFile,
|
||||
).toBe('test.file');
|
||||
reducers({ activeFileUuid: null } as State, editorDidActivateFile(testUuid))
|
||||
.activeFileUuid,
|
||||
).toBe(testUuid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,18 +35,18 @@ describe('openFiles', () => {
|
||||
it('should change state when a file is opened', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ openFiles: [] as readonly string[] } as State,
|
||||
editorDidOpenFile('test.file'),
|
||||
).openFiles,
|
||||
).toEqual(['test.file']);
|
||||
{ openFileUuids: [] as readonly string[] } as State,
|
||||
editorDidOpenFile(testUuid),
|
||||
).openFileUuids,
|
||||
).toEqual([testUuid]);
|
||||
});
|
||||
|
||||
it('should change state when a file is closed', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ openFiles: ['test.file'] as readonly string[] } as State,
|
||||
editorDidCloseFile('test.file'),
|
||||
).openFiles,
|
||||
{ openFileUuids: [testUuid] as readonly string[] } as State,
|
||||
editorDidCloseFile(testUuid),
|
||||
).openFileUuids,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-8
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import { UUID } from '../fileStorage';
|
||||
import {
|
||||
editorDidActivateFile,
|
||||
editorDidCloseFile,
|
||||
@@ -19,29 +20,33 @@ const isReady: Reducer<boolean> = (state = false, action) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates which file out of {@link openFiles} is the currently active file.
|
||||
* Indicates which file out of {@link openFileUuids} is the currently active file.
|
||||
*
|
||||
* If {@link activeFile} is not in {@link openFiles}, then there is no active file.
|
||||
* If {@link activeFileUuid} is not in {@link openFileUuids}, then there is no active file.
|
||||
*/
|
||||
const activeFile: Reducer<string> = (state = '', action) => {
|
||||
const activeFileUuid: Reducer<UUID | null> = (state = null, action) => {
|
||||
if (editorDidActivateFile.matches(action)) {
|
||||
return action.fileName;
|
||||
return action.uuid;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/** A list of open files in the order they should be displayed to the user. */
|
||||
const openFiles: Reducer<readonly string[]> = (state = [], action) => {
|
||||
const openFileUuids: Reducer<readonly UUID[]> = (state = [], action) => {
|
||||
if (editorDidOpenFile.matches(action)) {
|
||||
return [...state, action.fileName];
|
||||
return [...state, action.uuid];
|
||||
}
|
||||
|
||||
if (editorDidCloseFile.matches(action)) {
|
||||
return state.filter((f) => f !== action.fileName);
|
||||
return state.filter((f) => f !== action.uuid);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ isReady, activeFile, openFiles });
|
||||
export default combineReducers({
|
||||
isReady,
|
||||
activeFileUuid,
|
||||
openFileUuids,
|
||||
});
|
||||
|
||||
+54
-33
@@ -3,12 +3,14 @@
|
||||
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { monaco } from 'react-monaco-editor';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import {
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToLoadTextFile,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageReadFile,
|
||||
fileStorageDidLoadTextFile,
|
||||
fileStorageLoadTextFile,
|
||||
fileStorageStoreTextFileValue,
|
||||
fileStorageStoreTextFileViewState,
|
||||
} from '../fileStorage/actions';
|
||||
import { acquireLock } from '../utils';
|
||||
import {
|
||||
@@ -28,6 +30,9 @@ import editor from './sagas';
|
||||
|
||||
jest.mock('./lib');
|
||||
|
||||
const testFileUuid = uuid(0);
|
||||
const newTestFileUuid = uuid(1);
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
sessionStorage.clear();
|
||||
@@ -37,7 +42,7 @@ it('should activate files from storage', async () => {
|
||||
jest.spyOn(
|
||||
ActiveFileHistoryManager.prototype,
|
||||
'getFromStorage',
|
||||
).mockReturnValueOnce(['test.file'].values());
|
||||
).mockReturnValueOnce([testFileUuid].values());
|
||||
|
||||
const saga = new AsyncSaga(editor);
|
||||
|
||||
@@ -48,7 +53,7 @@ it('should activate files from storage', async () => {
|
||||
await expect(saga.take()).resolves.toEqual(editorDidCreate());
|
||||
|
||||
// other editor actions on create should take place after editorDidCreate()
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile('test.file'));
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile(testFileUuid));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -90,16 +95,16 @@ describe('per-editor sagas', () => {
|
||||
describe('handleEditorOpenFile', () => {
|
||||
it('should fail if file is already in use', async () => {
|
||||
const releaseLock = await acquireLock(
|
||||
'pybricks.editor+pybricksCode:test.file',
|
||||
`pybricks.editor+pybricksCode:${testFileUuid}`,
|
||||
);
|
||||
expect(releaseLock).toBeDefined();
|
||||
|
||||
try {
|
||||
saga.put(editorOpenFile('test.file'));
|
||||
saga.put(editorOpenFile(testFileUuid));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidFailToOpenFile(
|
||||
'test.file',
|
||||
testFileUuid,
|
||||
expectEditorError('FileInUse'),
|
||||
),
|
||||
);
|
||||
@@ -112,20 +117,20 @@ describe('per-editor sagas', () => {
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(OpenFileManager.prototype, 'add');
|
||||
jest.spyOn(OpenFileManager.prototype, 'remove');
|
||||
saga.put(editorOpenFile('test.file'));
|
||||
saga.put(editorOpenFile(testFileUuid));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageReadFile('test.file'),
|
||||
fileStorageLoadTextFile(testFileUuid),
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate error from fileStorageReadFile', async () => {
|
||||
it('should propagate error from fileStorageLoadTextFile', async () => {
|
||||
const testError = new Error('test error');
|
||||
|
||||
saga.put(fileStorageDidFailToReadFile('test.file', testError));
|
||||
saga.put(fileStorageDidFailToLoadTextFile(testFileUuid, testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidFailToOpenFile('test.file', testError),
|
||||
editorDidFailToOpenFile(testFileUuid, testError),
|
||||
);
|
||||
|
||||
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
|
||||
@@ -137,12 +142,12 @@ describe('per-editor sagas', () => {
|
||||
beforeEach(async () => {
|
||||
monaco.editor.onDidCreateModel((m) => (model = m));
|
||||
|
||||
saga.put(fileStorageDidReadFile('test.file', ''));
|
||||
saga.put(fileStorageDidLoadTextFile(testFileUuid, '', null));
|
||||
|
||||
expect(model).toBeDefined();
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidOpenFile('test.file'),
|
||||
editorDidOpenFile(testFileUuid),
|
||||
);
|
||||
|
||||
expect(OpenFileManager.prototype.add).toHaveBeenCalled();
|
||||
@@ -164,14 +169,22 @@ describe('per-editor sagas', () => {
|
||||
it('should close when requested', async () => {
|
||||
jest.spyOn(model, 'dispose');
|
||||
|
||||
saga.put(editorCloseFile('test.file'));
|
||||
saga.put(editorCloseFile(testFileUuid));
|
||||
|
||||
// file is saved on close
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageStoreTextFileValue(testFileUuid, ''),
|
||||
);
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageStoreTextFileViewState(testFileUuid, null),
|
||||
);
|
||||
|
||||
// model should be disposed before fileStorageClose
|
||||
expect(model.dispose).toHaveBeenCalled();
|
||||
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidCloseFile('test.file'),
|
||||
editorDidCloseFile(testFileUuid),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -182,16 +195,18 @@ describe('per-editor sagas', () => {
|
||||
describe('file is not already open', () => {
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(OpenFileManager.prototype, 'has').mockReturnValueOnce(false);
|
||||
saga.put(editorActivateFile('test.file'));
|
||||
await expect(saga.take()).resolves.toEqual(editorOpenFile('test.file'));
|
||||
saga.put(editorActivateFile(testFileUuid));
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorOpenFile(testFileUuid),
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate error if open fails', async () => {
|
||||
const testError = new Error('test error');
|
||||
saga.put(editorDidFailToOpenFile('test.file', testError));
|
||||
saga.put(editorDidFailToOpenFile(testFileUuid, testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidFailToActivateFile('test.file', testError),
|
||||
editorDidFailToActivateFile(testFileUuid, testError),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -205,16 +220,18 @@ describe('per-editor sagas', () => {
|
||||
);
|
||||
jest.spyOn(OpenFileManager.prototype, 'updateViewState');
|
||||
|
||||
saga.put(editorDidOpenFile('test.file'));
|
||||
saga.put(editorDidOpenFile(testFileUuid));
|
||||
|
||||
// changes should be made before editorDidActivateFile
|
||||
expect(OpenFileManager.prototype.updateViewState).toHaveBeenCalled();
|
||||
expect(
|
||||
OpenFileManager.prototype.updateViewState,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(monacoEditor.setModel).toHaveBeenCalled();
|
||||
expect(monacoEditor.restoreViewState).toHaveBeenCalled();
|
||||
expect(ActiveFileHistoryManager.prototype.push).toHaveBeenCalled();
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidActivateFile('test.file'),
|
||||
editorDidActivateFile(testFileUuid),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -229,17 +246,19 @@ describe('per-editor sagas', () => {
|
||||
jest.spyOn(monacoEditor, 'getModel').mockReturnValueOnce(null);
|
||||
jest.spyOn(monacoEditor, 'setModel').mockReturnValueOnce();
|
||||
jest.spyOn(monacoEditor, 'restoreViewState').mockReturnValueOnce();
|
||||
saga.put(editorActivateFile('test.file'));
|
||||
saga.put(editorActivateFile(testFileUuid));
|
||||
});
|
||||
|
||||
it('should set the model and update sessionStorage', async () => {
|
||||
// changes should be made before editorDidActivateFile
|
||||
expect(monacoEditor.setModel).toHaveBeenCalled();
|
||||
expect(monacoEditor.restoreViewState).toHaveBeenCalled();
|
||||
expect(OpenFileManager.prototype.updateViewState).toHaveBeenCalled();
|
||||
expect(
|
||||
OpenFileManager.prototype.updateViewState,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorDidActivateFile('test.file'),
|
||||
editorDidActivateFile(testFileUuid),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -248,21 +267,23 @@ describe('per-editor sagas', () => {
|
||||
describe('handleEditorDidCloseFile', () => {
|
||||
it('should activate a new file if closed file was currently active', async () => {
|
||||
jest.spyOn(ActiveFileHistoryManager.prototype, 'pop').mockReturnValueOnce(
|
||||
'new.file',
|
||||
newTestFileUuid,
|
||||
);
|
||||
|
||||
saga.put(editorDidCloseFile('test.file'));
|
||||
saga.put(editorDidCloseFile(testFileUuid));
|
||||
|
||||
expect(ActiveFileHistoryManager.prototype.pop).toHaveBeenCalled();
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile('new.file'));
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
editorActivateFile(newTestFileUuid),
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing if closed file was not currently active', () => {
|
||||
it('should do nothing if closed file was not currently active', async () => {
|
||||
jest.spyOn(ActiveFileHistoryManager.prototype, 'pop').mockReturnValueOnce(
|
||||
undefined,
|
||||
);
|
||||
|
||||
saga.put(editorDidCloseFile('test.file'));
|
||||
saga.put(editorDidCloseFile(testFileUuid));
|
||||
|
||||
expect(ActiveFileHistoryManager.prototype.pop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+155
-40
@@ -15,12 +15,14 @@ import {
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'typed-redux-saga/macro';
|
||||
import { UUID } from '../fileStorage';
|
||||
import {
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToLoadTextFile,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageReadFile,
|
||||
fileStorageWriteFile,
|
||||
fileStorageDidLoadTextFile,
|
||||
fileStorageLoadTextFile,
|
||||
fileStorageStoreTextFileValue,
|
||||
fileStorageStoreTextFileViewState,
|
||||
} from '../fileStorage/actions';
|
||||
import { RootState } from '../reducers';
|
||||
import { acquireLock, defined, ensureError } from '../utils';
|
||||
@@ -80,7 +82,7 @@ function* handleModelDidChange(
|
||||
yield* take(chan);
|
||||
const value = model.getValue();
|
||||
// when the model changes, save it to storage.
|
||||
yield* put(fileStorageWriteFile(model.uri.fsPath, value));
|
||||
yield* put(fileStorageStoreTextFileValue(model.uri.path as UUID, value));
|
||||
// failures are ignored
|
||||
|
||||
// throttle the writes so we don't do it too often while user is typing quickly
|
||||
@@ -89,6 +91,7 @@ function* handleModelDidChange(
|
||||
}
|
||||
|
||||
function* handleEditorOpenFile(
|
||||
editor: monaco.editor.ICodeEditor,
|
||||
openFiles: OpenFileManager,
|
||||
action: ReturnType<typeof editorOpenFile>,
|
||||
): Generator {
|
||||
@@ -100,7 +103,7 @@ function* handleEditorOpenFile(
|
||||
try {
|
||||
const modelUri = monaco.Uri.from({
|
||||
scheme: 'pybricksCode',
|
||||
path: action.fileName,
|
||||
path: action.uuid,
|
||||
});
|
||||
|
||||
const releaseLock = yield* call(() =>
|
||||
@@ -116,27 +119,27 @@ function* handleEditorOpenFile(
|
||||
|
||||
defer.push(releaseLock);
|
||||
|
||||
yield* put(fileStorageReadFile(modelUri.fsPath));
|
||||
yield* put(fileStorageLoadTextFile(action.uuid));
|
||||
|
||||
const { didRead, didFailToRead } = yield* race({
|
||||
didRead: take(
|
||||
fileStorageDidReadFile.when((a) => a.path === action.fileName),
|
||||
const { didLoad, didFailToLoad } = yield* race({
|
||||
didLoad: take(
|
||||
fileStorageDidLoadTextFile.when((a) => a.uuid === action.uuid),
|
||||
),
|
||||
didFailToRead: take(
|
||||
fileStorageDidFailToReadFile.when(
|
||||
(a) => a.path === action.fileName,
|
||||
didFailToLoad: take(
|
||||
fileStorageDidFailToLoadTextFile.when(
|
||||
(a) => a.uuid === action.uuid,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToRead) {
|
||||
throw didFailToRead.error;
|
||||
if (didFailToLoad) {
|
||||
throw didFailToLoad.error;
|
||||
}
|
||||
|
||||
defined(didRead);
|
||||
defined(didLoad);
|
||||
|
||||
const model = monaco.editor.createModel(
|
||||
didRead.contents,
|
||||
didLoad.value,
|
||||
pybricksMicroPythonId,
|
||||
modelUri,
|
||||
);
|
||||
@@ -156,16 +159,30 @@ function* handleEditorOpenFile(
|
||||
// https://github.com/redux-saga/redux-saga/issues/620#issuecomment-259161095
|
||||
yield* fork(handleModelDidChange, 1000, didChangeModelChan, model);
|
||||
|
||||
// TODO: get viewState from fileStorage
|
||||
openFiles.add(action.uuid, model, didLoad.viewState);
|
||||
defer.push(() => openFiles.remove(action.uuid));
|
||||
|
||||
openFiles.add(action.fileName, model, null);
|
||||
defer.push(() => openFiles.remove(action.fileName));
|
||||
yield* put(editorDidOpenFile(action.uuid));
|
||||
|
||||
yield* put(editorDidOpenFile(action.fileName));
|
||||
|
||||
yield* take(editorCloseFile.when((a) => a.fileName === action.fileName));
|
||||
yield* take(editorCloseFile.when((a) => a.uuid === action.uuid));
|
||||
|
||||
closeRequested = true;
|
||||
|
||||
// if the file is the currently active file, the view state will
|
||||
// be out of sync, so we need to update it here before saving to
|
||||
// storage
|
||||
if (editor.getModel() === model) {
|
||||
openFiles.updateViewState(action.uuid, editor.saveViewState());
|
||||
}
|
||||
|
||||
// save the file contents and view state on close
|
||||
yield* put(fileStorageStoreTextFileValue(action.uuid, model.getValue()));
|
||||
yield* put(
|
||||
fileStorageStoreTextFileViewState(
|
||||
action.uuid,
|
||||
openFiles.get(action.uuid)?.viewState ?? null,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
for (const callback of defer.reverse()) {
|
||||
callback();
|
||||
@@ -173,11 +190,11 @@ function* handleEditorOpenFile(
|
||||
|
||||
// only send the did close action if the corresponding action requested it
|
||||
if (closeRequested) {
|
||||
yield* put(editorDidCloseFile(action.fileName));
|
||||
yield* put(editorDidCloseFile(action.uuid));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
yield* put(editorDidFailToOpenFile(action.fileName, ensureError(err)));
|
||||
yield* put(editorDidFailToOpenFile(action.uuid, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,15 +205,13 @@ function* handleEditorActivateFile(
|
||||
action: ReturnType<typeof editorActivateFile>,
|
||||
): Generator {
|
||||
try {
|
||||
if (!openFiles.has(action.fileName)) {
|
||||
yield* put(editorOpenFile(action.fileName));
|
||||
if (!openFiles.has(action.uuid)) {
|
||||
yield* put(editorOpenFile(action.uuid));
|
||||
|
||||
const { didFailToOpen } = yield* race({
|
||||
didOpen: take(
|
||||
editorDidOpenFile.when((a) => a.fileName == action.fileName),
|
||||
),
|
||||
didOpen: take(editorDidOpenFile.when((a) => a.uuid == action.uuid)),
|
||||
didFailToOpen: take(
|
||||
editorDidFailToOpenFile.when((a) => a.fileName == action.fileName),
|
||||
editorDidFailToOpenFile.when((a) => a.uuid == action.uuid),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -205,7 +220,7 @@ function* handleEditorActivateFile(
|
||||
}
|
||||
}
|
||||
|
||||
const file = openFiles.get(action.fileName);
|
||||
const file = openFiles.get(action.uuid);
|
||||
|
||||
// istanbul ignore if: this should always be available after editorDidOpenFile
|
||||
if (file === undefined) {
|
||||
@@ -213,17 +228,22 @@ function* handleEditorActivateFile(
|
||||
}
|
||||
|
||||
// save the current view state for later activation
|
||||
const activeFile = editor.getModel()?.uri?.path ?? '';
|
||||
openFiles.updateViewState(activeFile, editor.saveViewState());
|
||||
// TODO: save viewState to fileStorage
|
||||
const oldModel = editor.getModel();
|
||||
|
||||
if (oldModel) {
|
||||
openFiles.updateViewState(
|
||||
oldModel.uri.path as UUID,
|
||||
editor.saveViewState(),
|
||||
);
|
||||
}
|
||||
|
||||
editor.setModel(file.model);
|
||||
editor.restoreViewState(file.viewState);
|
||||
activeFileHistory.push(action.fileName);
|
||||
activeFileHistory.push(action.uuid);
|
||||
|
||||
yield* put(editorDidActivateFile(action.fileName));
|
||||
yield* put(editorDidActivateFile(action.uuid));
|
||||
} catch (err) {
|
||||
yield* put(editorDidFailToActivateFile(action.fileName, ensureError(err)));
|
||||
yield* put(editorDidFailToActivateFile(action.uuid, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +255,7 @@ function* handleEditorDidCloseFile(
|
||||
// Here we only need to handle removing the closed file from the active
|
||||
// file history.
|
||||
|
||||
const newActiveFile = activeFileHistory.pop(action.fileName);
|
||||
const newActiveFile = activeFileHistory.pop(action.uuid);
|
||||
|
||||
// if the closed file was the active file, we need to activate a new file
|
||||
// otherwise there will be no active file
|
||||
@@ -244,6 +264,57 @@ function* handleEditorDidCloseFile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor browser document visibility and save active file if visibility is lost.
|
||||
*/
|
||||
function* monitorDocumentVisibility(editor: monaco.editor.ICodeEditor): Generator {
|
||||
const ch = eventChannel<Event>((emit) => {
|
||||
// document visibility is the most reliable way to monitor end of "session".
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
|
||||
document.addEventListener('visibilitychange', emit);
|
||||
return () => document.removeEventListener('visibilitychange', emit);
|
||||
});
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
yield* take(ch);
|
||||
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
// remove the backup so that we don't risk writing over newer
|
||||
// data with older data from the backup
|
||||
sessionStorage.removeItem('editor.backup');
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = editor.getModel();
|
||||
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is a last-ditch effort to save the user state when the page
|
||||
// "closes" (reload, background on mobile, etc.). We can't write to
|
||||
// indexeddb here since it is async and won't complete the transaction
|
||||
// before the page stops running.
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'editor.backup',
|
||||
JSON.stringify({
|
||||
uuid: model.uri.path,
|
||||
value: model.getValue(),
|
||||
viewState: editor.saveViewState(),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// istanbul ignore next: not critical if this fails
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ch.close();
|
||||
}
|
||||
}
|
||||
|
||||
function* handleDidCreateEditor(editor: monaco.editor.ICodeEditor): Generator {
|
||||
// first, we need to be sure that file storage is ready
|
||||
|
||||
@@ -259,7 +330,7 @@ function* handleDidCreateEditor(editor: monaco.editor.ICodeEditor): Generator {
|
||||
const activeFileHistory = new ActiveFileHistoryManager(editor.getId());
|
||||
|
||||
yield* takeEvery(editorGetValueRequest, handleEditorGetValueRequest, editor);
|
||||
yield* takeEvery(editorOpenFile, handleEditorOpenFile, openFiles);
|
||||
yield* takeEvery(editorOpenFile, handleEditorOpenFile, editor, openFiles);
|
||||
yield* takeEvery(
|
||||
editorActivateFile,
|
||||
handleEditorActivateFile,
|
||||
@@ -268,14 +339,58 @@ function* handleDidCreateEditor(editor: monaco.editor.ICodeEditor): Generator {
|
||||
activeFileHistory,
|
||||
);
|
||||
yield* takeEvery(editorDidCloseFile, handleEditorDidCloseFile, activeFileHistory);
|
||||
yield* fork(monitorDocumentVisibility, editor);
|
||||
|
||||
yield* put(editorDidCreate());
|
||||
|
||||
const backup = (() => {
|
||||
try {
|
||||
const item = sessionStorage.getItem('editor.backup');
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem('editor.backup');
|
||||
|
||||
return JSON.parse(item);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// this should restore all previously open files in the same order
|
||||
// the were last used (which may be different from the order in which
|
||||
// they were originally opened)
|
||||
for (const item of activeFileHistory.getFromStorage()) {
|
||||
yield* put(editorActivateFile(item));
|
||||
|
||||
const { didActivate } = yield* race({
|
||||
didActivate: take(editorDidActivateFile.when((a) => a.uuid === item)),
|
||||
didFailToActivate: take(
|
||||
editorDidFailToActivateFile.when((a) => a.uuid === item),
|
||||
),
|
||||
});
|
||||
|
||||
if (didActivate && didActivate.uuid === backup.uuid) {
|
||||
if (backup.value) {
|
||||
try {
|
||||
editor.getModel()?.setValue(backup.value);
|
||||
} catch (err) {
|
||||
// istanbul ignore next: not critical if this fails
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (backup.viewState) {
|
||||
try {
|
||||
editor.restoreViewState(backup.viewState);
|
||||
} catch (err) {
|
||||
// istanbul ignore next: not critical if this fails
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BleDeviceFailToConnectReasonType,
|
||||
didFailToConnect as bleDeviceDidFailToConnect,
|
||||
} from '../ble/actions';
|
||||
import { fileStorageDidFailToStoreTextFileValue } from '../fileStorage/actions';
|
||||
import {
|
||||
BootloaderConnectionFailureReason,
|
||||
didError as bootloaderDidError,
|
||||
@@ -46,10 +47,20 @@ function handleBootloaderDidError(action: ReturnType<typeof bootloaderDidError>)
|
||||
console.error(action.err);
|
||||
}
|
||||
|
||||
function handleFileStorageDidFailToStoreTextFileValue(
|
||||
action: ReturnType<typeof fileStorageDidFailToStoreTextFileValue>,
|
||||
): void {
|
||||
console.error(action.error);
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* takeEvery(bleDeviceDidFailToConnect, handleBleDeviceDidFailToConnect);
|
||||
yield* takeEvery(pybricksEventProtocolError, handlePybricksEventProtocolError);
|
||||
yield* takeEvery(bleUartDidFailToWrite, handleBleUartDidFailToWrite);
|
||||
yield* takeEvery(bootloaderDidFailToConnect, handleBootloaderDidFailToConnect);
|
||||
yield* takeEvery(bootloaderDidError, handleBootloaderDidError);
|
||||
yield* takeEvery(
|
||||
fileStorageDidFailToStoreTextFileValue,
|
||||
handleFileStorageDidFailToStoreTextFileValue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const testFile: FileMetadata = {
|
||||
uuid: uuid(0),
|
||||
path: 'test.file',
|
||||
sha256: '',
|
||||
viewState: null,
|
||||
};
|
||||
|
||||
describe('archive button', () => {
|
||||
@@ -75,7 +76,9 @@ describe('tree item', () => {
|
||||
|
||||
userEvent.click(treeItem);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerUserActivateFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
explorerUserActivateFile('test.file', uuid(0)),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch action when key is pressed', async () => {
|
||||
@@ -87,7 +90,9 @@ describe('tree item', () => {
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{enter}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerUserActivateFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
explorerUserActivateFile('test.file', uuid(0)),
|
||||
);
|
||||
});
|
||||
|
||||
describe('duplicate', () => {
|
||||
@@ -191,7 +196,9 @@ describe('tree item', () => {
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
explorerDeleteFile('test.file', uuid(0)),
|
||||
);
|
||||
|
||||
// should not propagate to treeitem
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
@@ -206,7 +213,9 @@ describe('tree item', () => {
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{del}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
explorerDeleteFile('test.file', uuid(0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Toolbar } from '../components/toolbar/Toolbar';
|
||||
import { useToolbarItemFocus } from '../components/toolbar/aria';
|
||||
import { UUID } from '../fileStorage';
|
||||
import { useFileStorageMetadata } from '../fileStorage/hooks';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
|
||||
@@ -147,7 +148,9 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
|
||||
id={deleteButtonId}
|
||||
icon="trash"
|
||||
tooltip={i18n.translate(I18nId.TreeItemDeleteTooltip, { fileName })}
|
||||
onClick={() => dispatch(explorerDeleteFile(fileName))}
|
||||
onClick={() =>
|
||||
dispatch(explorerDeleteFile(fileName, item.index as UUID))
|
||||
}
|
||||
/>
|
||||
</Toolbar>
|
||||
</ButtonGroup>
|
||||
@@ -272,7 +275,7 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
|
||||
const handleDeleteKeyDown = useCallback(() => {
|
||||
if (focusedItem !== undefined) {
|
||||
const fileName = environment.getItemTitle(environment.items[focusedItem]);
|
||||
dispatch(explorerDeleteFile(fileName));
|
||||
dispatch(explorerDeleteFile(fileName, focusedItem as UUID));
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
@@ -389,7 +392,9 @@ const FileTree: React.VoidFunctionComponent = () => {
|
||||
canRename={false} // we implement our own rename handler
|
||||
onFocusItem={(item) => setFocusedItem(item.index)}
|
||||
onPrimaryAction={(item) =>
|
||||
dispatch(explorerUserActivateFile(item.data.fileName))
|
||||
dispatch(
|
||||
explorerUserActivateFile(item.data.fileName, item.index as UUID),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="pb-explorer-file-tree">
|
||||
|
||||
+12
-5
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createAction } from '../actions';
|
||||
import { UUID } from '../fileStorage';
|
||||
/**
|
||||
* Request to archive (download) all files in the store.
|
||||
*/
|
||||
@@ -74,11 +75,15 @@ export const explorerDidFailToCreateNewFile = createAction((error: Error) => ({
|
||||
/**
|
||||
* Request to activate a file (open or bring to foreground if already open).
|
||||
* @param fileName The file name.
|
||||
* @param uuid The file metadata UUID.
|
||||
*/
|
||||
export const explorerUserActivateFile = createAction((fileName: string) => ({
|
||||
type: 'explorer.user.action.activateFile',
|
||||
fileName,
|
||||
}));
|
||||
export const explorerUserActivateFile = createAction(
|
||||
(fileName: string, uuid: UUID) => ({
|
||||
type: 'explorer.user.action.activateFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Indicates that {@link explorerUserActivateFile} completed.
|
||||
@@ -177,10 +182,12 @@ export const explorerDidFailToExportFile = createAction(
|
||||
/**
|
||||
* Action that requests to delete a file.
|
||||
* @param fileName The file name.
|
||||
* @param uuid The file metadata UUID.
|
||||
*/
|
||||
export const explorerDeleteFile = createAction((fileName: string) => ({
|
||||
export const explorerDeleteFile = createAction((fileName: string, uuid: UUID) => ({
|
||||
type: 'explorer.action.deleteFile',
|
||||
fileName,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
+12
-12
@@ -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 } from '../../test';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import {
|
||||
editorActivateFile,
|
||||
@@ -174,7 +174,7 @@ describe('handleExplorerImportFiles', () => {
|
||||
fileStorageWriteFile(testFileName, testFileContents),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(testFileName));
|
||||
saga.put(fileStorageDidWriteFile(testFileName, uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
@@ -237,9 +237,9 @@ describe('handleExplorerCreateNewFile', () => {
|
||||
}
|
||||
`);
|
||||
|
||||
saga.put(fileStorageDidWriteFile('test.py'));
|
||||
saga.put(fileStorageDidWriteFile('test.py', uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile('test.py'));
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile(uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidCreateNewFile());
|
||||
});
|
||||
@@ -255,14 +255,14 @@ describe('handleExplorerActivateFile', () => {
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(explorer);
|
||||
|
||||
saga.put(explorerUserActivateFile('test.file'));
|
||||
saga.put(explorerUserActivateFile('test.file', uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile('test.file'));
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile(uuid(0)));
|
||||
});
|
||||
|
||||
it('should alert file in use error', async () => {
|
||||
const testError = new EditorError('FileInUse', 'test error');
|
||||
saga.put(editorDidFailToActivateFile('test.file', testError));
|
||||
saga.put(editorDidFailToActivateFile(uuid(0), testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
alertsShowAlert('explorer', 'fileInUse', { fileName: 'test.file' }),
|
||||
@@ -274,7 +274,7 @@ describe('handleExplorerActivateFile', () => {
|
||||
|
||||
it('should alert unexpected error', async () => {
|
||||
const testError = new Error('test error');
|
||||
saga.put(editorDidFailToActivateFile('test.file', testError));
|
||||
saga.put(editorDidFailToActivateFile(uuid(0), testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
alertsShowAlert('alerts', 'unexpectedError', { error: testError }),
|
||||
@@ -285,7 +285,7 @@ describe('handleExplorerActivateFile', () => {
|
||||
});
|
||||
|
||||
it('should notify success', async () => {
|
||||
saga.put(editorDidActivateFile('test.file'));
|
||||
saga.put(editorDidActivateFile(uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerUserDidActivateFile('test.file'),
|
||||
@@ -455,7 +455,7 @@ describe('handleExplorerDeleteFile', () => {
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(explorer);
|
||||
|
||||
saga.put(explorerDeleteFile(testFile));
|
||||
saga.put(explorerDeleteFile(testFile, uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(deleteFileAlertShow(testFile));
|
||||
});
|
||||
@@ -476,8 +476,8 @@ describe('handleExplorerDeleteFile', () => {
|
||||
saga.put(deleteFileAlertDidAccept());
|
||||
|
||||
// should close the editor first
|
||||
await expect(saga.take()).resolves.toEqual(editorCloseFile(testFile));
|
||||
saga.put(editorDidCloseFile(testFile));
|
||||
await expect(saga.take()).resolves.toEqual(editorCloseFile(uuid(0)));
|
||||
saga.put(editorDidCloseFile(uuid(0)));
|
||||
|
||||
// then delete the file
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDeleteFile(testFile));
|
||||
|
||||
@@ -214,7 +214,7 @@ function* handleExplorerCreateNewFile(): Generator {
|
||||
),
|
||||
);
|
||||
|
||||
const { didFailToWrite } = yield* race({
|
||||
const { didWrite, didFailToWrite } = yield* race({
|
||||
didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)),
|
||||
didFailToWrite: take(
|
||||
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
|
||||
@@ -225,7 +225,9 @@ function* handleExplorerCreateNewFile(): Generator {
|
||||
throw didFailToWrite.error;
|
||||
}
|
||||
|
||||
yield* put(editorActivateFile(fileName));
|
||||
defined(didWrite);
|
||||
|
||||
yield* put(editorActivateFile(didWrite.uuid));
|
||||
|
||||
yield* put(explorerDidCreateNewFile());
|
||||
} catch (err) {
|
||||
@@ -240,14 +242,12 @@ function* handleExplorerCreateNewFile(): Generator {
|
||||
function* handleExplorerActivateFile(
|
||||
action: ReturnType<typeof explorerUserActivateFile>,
|
||||
): Generator {
|
||||
yield* put(editorActivateFile(action.fileName));
|
||||
yield* put(editorActivateFile(action.uuid));
|
||||
|
||||
const { didFailToActivate } = yield* race({
|
||||
didActivate: take(
|
||||
editorDidActivateFile.when((a) => a.fileName === action.fileName),
|
||||
),
|
||||
didActivate: take(editorDidActivateFile.when((a) => a.uuid === action.uuid)),
|
||||
didFailToActivate: take(
|
||||
editorDidFailToActivateFile.when((a) => a.fileName === action.fileName),
|
||||
editorDidFailToActivateFile.when((a) => a.uuid === action.uuid),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -406,8 +406,8 @@ function* handleExplorerDeleteFile(action: ReturnType<typeof explorerDeleteFile>
|
||||
// at this point we know the user accepted
|
||||
|
||||
// have to close editor before deleting, otherwise we get "in use" error
|
||||
yield* put(editorCloseFile(action.fileName));
|
||||
yield* take(editorDidCloseFile.when((a) => a.fileName === action.fileName));
|
||||
yield* put(editorCloseFile(action.uuid));
|
||||
yield* take(editorDidCloseFile.when((a) => a.uuid === action.uuid));
|
||||
|
||||
yield* put(fileStorageDeleteFile(action.fileName));
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import type { monaco } from 'react-monaco-editor';
|
||||
import { createAction } from '../actions';
|
||||
import { FileMetadata } from '.';
|
||||
import { FileMetadata, UUID } from '.';
|
||||
|
||||
/** File open modes. */
|
||||
export type FileOpenMode = 'r' | 'w';
|
||||
@@ -48,11 +49,13 @@ export const fileStorageOpen = createAction(
|
||||
/**
|
||||
* Action that indicates that {@link fileStorageOpen} succeeded.
|
||||
* @param path The file path.
|
||||
* @param uuid The UUID of the file metadata.
|
||||
* @param fd The file descriptor.
|
||||
*/
|
||||
export const fileStorageDidOpen = createAction((path: string, fd: FD) => ({
|
||||
export const fileStorageDidOpen = createAction((path: string, uuid: UUID, fd: FD) => ({
|
||||
type: 'fileStorage.action.DidOpen',
|
||||
path,
|
||||
uuid,
|
||||
fd,
|
||||
}));
|
||||
|
||||
@@ -199,10 +202,12 @@ export const fileStorageWriteFile = createAction((path: string, contents: string
|
||||
/**
|
||||
* Indicates that {@link fileStorageWriteFile} succeeded.
|
||||
* @param path: The file path.
|
||||
* @param uuid: The UUID of the file metadata.
|
||||
*/
|
||||
export const fileStorageDidWriteFile = createAction((path: string) => ({
|
||||
export const fileStorageDidWriteFile = createAction((path: string, uuid: UUID) => ({
|
||||
type: 'fileStorage.action.didWriteFile',
|
||||
path,
|
||||
uuid,
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -341,3 +346,71 @@ export const fileStorageDidFailToDumpAllFiles = createAction((error: Error) => (
|
||||
type: 'fileStorage.action.didFailToDumpAllFiles',
|
||||
error,
|
||||
}));
|
||||
|
||||
export const fileStorageLoadTextFile = createAction((uuid: UUID) => ({
|
||||
type: 'fileStorage.action.loadTextFile',
|
||||
uuid,
|
||||
}));
|
||||
|
||||
export const fileStorageDidLoadTextFile = createAction(
|
||||
(
|
||||
uuid: UUID,
|
||||
value: string,
|
||||
viewState: monaco.editor.ICodeEditorViewState | null,
|
||||
) => ({
|
||||
type: 'fileStorage.action.didLoadTextFile',
|
||||
uuid,
|
||||
value,
|
||||
viewState,
|
||||
}),
|
||||
);
|
||||
|
||||
export const fileStorageDidFailToLoadTextFile = createAction(
|
||||
(uuid: UUID, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToLoadTextFile',
|
||||
uuid,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
export const fileStorageStoreTextFileValue = createAction(
|
||||
(uuid: UUID, value: string) => ({
|
||||
type: 'fileStorage.action.storeTextFileValue',
|
||||
uuid,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
|
||||
export const fileStorageDidStoreTextFileValue = createAction((uuid: UUID) => ({
|
||||
type: 'fileStorage.action.didStoreTextFileValue',
|
||||
uuid,
|
||||
}));
|
||||
|
||||
export const fileStorageDidFailToStoreTextFileValue = createAction(
|
||||
(uuid: UUID, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToStoreTextFileValue',
|
||||
uuid,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
export const fileStorageStoreTextFileViewState = createAction(
|
||||
(uuid: UUID, viewState: monaco.editor.ICodeEditorViewState | null) => ({
|
||||
type: 'fileStorage.action.storeTextFileViewState',
|
||||
uuid,
|
||||
viewState,
|
||||
}),
|
||||
);
|
||||
|
||||
export const fileStorageDidStoreTextFileViewState = createAction((uuid: UUID) => ({
|
||||
type: 'fileStorage.action.didStoreTextFileViewState',
|
||||
uuid,
|
||||
}));
|
||||
|
||||
export const fileStorageDidFailToStoreTextFileViewState = createAction(
|
||||
(uuid: UUID, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToStoreTextFileViewState',
|
||||
uuid,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import 'dexie-observable';
|
||||
import Dexie, { Table } from 'dexie';
|
||||
import type { monaco } from 'react-monaco-editor';
|
||||
|
||||
/** Type to avoid mixing UUID with regular string. */
|
||||
export type UUID = string & { _uuidBrand: undefined };
|
||||
@@ -19,6 +20,8 @@ export type FileMetadata = Readonly<{
|
||||
path: string;
|
||||
/** The SHA256 hash of the file contents. */
|
||||
sha256: string;
|
||||
/** The text editor view state. */
|
||||
viewState: monaco.editor.ICodeEditorViewState | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -44,7 +47,7 @@ export class FileStorageDb extends Dexie {
|
||||
constructor(databaseName: string) {
|
||||
super(databaseName);
|
||||
this.version(1).stores({
|
||||
metadata: '$$uuid, &path, sha256',
|
||||
metadata: '$$uuid, &path, sha256, viewState',
|
||||
_contents: 'path, contents',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> {
|
||||
uuid: testFileId,
|
||||
path: testFilePath,
|
||||
sha256: testFileContentsSha256,
|
||||
viewState: null,
|
||||
};
|
||||
|
||||
saga.put(fileStorageOpen(testFilePath, 'w', true));
|
||||
@@ -118,7 +119,12 @@ describe('initialize', () => {
|
||||
// new storage backend
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidInitialize([
|
||||
{ uuid: uuid(0), path: 'main.py', sha256: oldProgramContentsSha256 },
|
||||
{
|
||||
uuid: uuid(0),
|
||||
path: 'main.py',
|
||||
sha256: oldProgramContentsSha256,
|
||||
viewState: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(localStorage.getItem(oldProgramKey)).toBeNull();
|
||||
@@ -170,7 +176,7 @@ describe('open', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'w', true));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 0 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 0 as FD),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -203,7 +209,7 @@ describe('open', () => {
|
||||
saga.put(fileStorageOpen('test.file', mode, false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -214,13 +220,13 @@ describe('open', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'r', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 2 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 2 as FD),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -230,7 +236,7 @@ describe('open', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'w', false));
|
||||
@@ -247,7 +253,7 @@ describe('open', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'w', true));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 0 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 0 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(0 as FD));
|
||||
@@ -279,7 +285,7 @@ describe('read', () => {
|
||||
saga.put(fileStorageOpen('test.file', mode, false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageRead(1 as FD));
|
||||
@@ -297,7 +303,7 @@ describe('read', () => {
|
||||
saga.put(fileStorageOpen('test.file', mode, false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(1 as FD));
|
||||
@@ -334,7 +340,7 @@ describe('write', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'w', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageWrite(1 as FD, 'new contents'));
|
||||
@@ -348,7 +354,7 @@ describe('write', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageWrite(1 as FD, 'new contents'));
|
||||
@@ -368,7 +374,7 @@ describe('write', () => {
|
||||
saga.put(fileStorageOpen('test.file', mode, false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(1 as FD));
|
||||
@@ -416,7 +422,7 @@ describe('readFile', () => {
|
||||
|
||||
describe('should open file', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(fileStorageDidOpen('test.file', 0 as FD));
|
||||
saga.put(fileStorageDidOpen('test.file', uuid(0), 0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageRead(0 as FD));
|
||||
});
|
||||
@@ -483,7 +489,7 @@ describe('writeFile', () => {
|
||||
|
||||
describe('should open file', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(fileStorageDidOpen('test.file', 0 as FD));
|
||||
saga.put(fileStorageDidOpen('test.file', uuid(0), 0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWrite(0 as FD, contents),
|
||||
@@ -515,7 +521,7 @@ describe('writeFile', () => {
|
||||
saga.put(fileStorageDidClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidWriteFile('test.file'),
|
||||
fileStorageDidWriteFile('test.file', uuid(0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -549,7 +555,7 @@ describe('copyFile', () => {
|
||||
it('should fail if new file is open', async () => {
|
||||
saga.put(fileStorageOpen('new.file', 'w', true));
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('new.file', 1 as FD),
|
||||
fileStorageDidOpen('new.file', uuid(1), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageCopyFile('test.file', 'new.file'));
|
||||
@@ -565,7 +571,7 @@ describe('copyFile', () => {
|
||||
it('should fail if new file exists', async () => {
|
||||
saga.put(fileStorageOpen('new.file', 'w', true));
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('new.file', 1 as FD),
|
||||
fileStorageDidOpen('new.file', uuid(1), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(1 as FD));
|
||||
@@ -617,7 +623,7 @@ describe('deleteFile', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
saga.put(fileStorageDeleteFile('test.file'));
|
||||
|
||||
@@ -669,7 +675,7 @@ describe('renameFile', () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r', false));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
fileStorageDidOpen('test.file', uuid(0), 1 as FD),
|
||||
);
|
||||
saga.put(fileStorageRenameFile('test.file', newPath));
|
||||
|
||||
@@ -688,7 +694,7 @@ describe('renameFile', () => {
|
||||
saga.put(fileStorageOpen(newPath, 'w', true));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen(newPath, 1 as FD),
|
||||
fileStorageDidOpen(newPath, uuid(1), 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageRenameFile('test.file', newPath));
|
||||
|
||||
+106
-2
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import Dexie from 'dexie';
|
||||
import {
|
||||
call,
|
||||
fork,
|
||||
@@ -27,24 +28,33 @@ import {
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
fileStorageDidFailToInitialize,
|
||||
fileStorageDidFailToLoadTextFile,
|
||||
fileStorageDidFailToOpen,
|
||||
fileStorageDidFailToRead,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidFailToStoreTextFileValue,
|
||||
fileStorageDidFailToStoreTextFileViewState,
|
||||
fileStorageDidFailToWrite,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidLoadTextFile,
|
||||
fileStorageDidOpen,
|
||||
fileStorageDidRead,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidStoreTextFileValue,
|
||||
fileStorageDidStoreTextFileViewState,
|
||||
fileStorageDidWrite,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageDumpAllFiles,
|
||||
fileStorageLoadTextFile,
|
||||
fileStorageOpen,
|
||||
fileStorageRead,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageStoreTextFileValue,
|
||||
fileStorageStoreTextFileViewState,
|
||||
fileStorageWrite,
|
||||
fileStorageWriteFile,
|
||||
} from './actions';
|
||||
@@ -115,6 +125,7 @@ function* handleOpen(
|
||||
const key = await db.metadata.add((<Omit<FileMetadata, 'uuid'>>{
|
||||
path: action.path,
|
||||
sha256,
|
||||
viewState: null,
|
||||
}) as FileMetadata);
|
||||
|
||||
await db._contents.put({ path: action.path, contents });
|
||||
@@ -131,7 +142,7 @@ function* handleOpen(
|
||||
|
||||
openFds.set(fd, { mode: action.mode, uuid });
|
||||
|
||||
yield* put(fileStorageDidOpen(action.path, fd));
|
||||
yield* put(fileStorageDidOpen(action.path, uuid, fd));
|
||||
|
||||
yield* take(fileStorageClose.when((a) => a.fd === fd));
|
||||
|
||||
@@ -353,7 +364,7 @@ function* handleWriteFile(action: ReturnType<typeof fileStorageWriteFile>): Gene
|
||||
yield* take(fileStorageDidClose.when((a) => a.fd === didOpen.fd));
|
||||
}
|
||||
|
||||
yield* put(fileStorageDidWriteFile(action.path));
|
||||
yield* put(fileStorageDidWriteFile(action.path, didOpen.uuid));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToWriteFile(action.path, ensureError(err)));
|
||||
}
|
||||
@@ -571,6 +582,91 @@ function* handleDumpAllFiles(db: FileStorageDb): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
function* handleLoadTextFile(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageLoadTextFile>,
|
||||
): Generator {
|
||||
try {
|
||||
const { value, viewState } = yield* call(() =>
|
||||
db.transaction('r', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata.get(action.uuid);
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error(`file with uuid '${action.uuid}' not found`);
|
||||
}
|
||||
|
||||
const content = await db._contents.get(metadata.path);
|
||||
|
||||
if (!content) {
|
||||
throw new Error(`content for file '${metadata.path}' not found`);
|
||||
}
|
||||
|
||||
return { value: content.contents, viewState: metadata.viewState };
|
||||
}),
|
||||
);
|
||||
|
||||
yield* put(fileStorageDidLoadTextFile(action.uuid, value, viewState));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToLoadTextFile(action.uuid, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function* handleStoreTextFileValue(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageStoreTextFileValue>,
|
||||
): Generator {
|
||||
try {
|
||||
yield* call(() =>
|
||||
db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata.get(action.uuid);
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error(`file with uuid '${action.uuid}' not found`);
|
||||
}
|
||||
|
||||
const sha256 = await Dexie.waitFor(sha256Digest(action.value));
|
||||
|
||||
await db.metadata.update(metadata.uuid, { sha256 });
|
||||
|
||||
await db._contents.put({ path: metadata.path, contents: action.value });
|
||||
}),
|
||||
);
|
||||
|
||||
yield* put(fileStorageDidStoreTextFileValue(action.uuid));
|
||||
} catch (err) {
|
||||
yield* put(
|
||||
fileStorageDidFailToStoreTextFileValue(action.uuid, ensureError(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function* handleStoreTextFileViewState(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageStoreTextFileViewState>,
|
||||
): Generator {
|
||||
try {
|
||||
yield* call(() =>
|
||||
db.transaction('rw', db.metadata, async () => {
|
||||
const metadata = await db.metadata.get(action.uuid);
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error(`file with uuid '${action.uuid}' not found`);
|
||||
}
|
||||
|
||||
await db.metadata.update(metadata.uuid, {
|
||||
viewState: action.viewState,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
yield* put(fileStorageDidStoreTextFileViewState(action.uuid));
|
||||
} catch (err) {
|
||||
yield* put(
|
||||
fileStorageDidFailToStoreTextFileViewState(action.uuid, ensureError(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the storage backend.
|
||||
*/
|
||||
@@ -595,6 +691,7 @@ function* initialize(): Generator {
|
||||
await db.metadata.add((<Omit<FileMetadata, 'uuid'>>{
|
||||
path: 'main.py',
|
||||
sha256,
|
||||
viewState: null,
|
||||
}) as FileMetadata);
|
||||
|
||||
await db._contents.add({ path: 'main.py', contents: oldProgram });
|
||||
@@ -622,6 +719,13 @@ function* initialize(): Generator {
|
||||
yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db);
|
||||
yield* takeEvery(fileStorageRenameFile, handleRenameFile, db);
|
||||
yield* takeEvery(fileStorageDumpAllFiles, handleDumpAllFiles, db);
|
||||
yield* takeEvery(fileStorageLoadTextFile, handleLoadTextFile, db);
|
||||
yield* takeEvery(fileStorageStoreTextFileValue, handleStoreTextFileValue, db);
|
||||
yield* takeEvery(
|
||||
fileStorageStoreTextFileViewState,
|
||||
handleStoreTextFileViewState,
|
||||
db,
|
||||
);
|
||||
|
||||
const files = yield* call(() => db.metadata.toArray());
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@pybricks/firmware';
|
||||
import { I18nManager } from '@shopify/react-i18n';
|
||||
import { AnyAction } from 'redux';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import { appDidCheckForUpdate } from '../app/actions';
|
||||
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
didFailToConnect as bleDidFailToConnect,
|
||||
} from '../ble/actions';
|
||||
import { editorDidFailToOpenFile } from '../editor/actions';
|
||||
import { EditorError } from '../editor/error';
|
||||
import {
|
||||
explorerDidFailToArchiveAllFiles,
|
||||
explorerDidFailToCreateNewFile,
|
||||
@@ -116,7 +117,7 @@ test.each([
|
||||
explorerDidFailToDuplicateFile('test.file', new Error('test error')),
|
||||
explorerDidFailToExportFile('test.file', new Error('test error')),
|
||||
explorerDidFailToDeleteFile('test.file', new Error('test error')),
|
||||
editorDidFailToOpenFile('test.file', new Error('test error')),
|
||||
editorDidFailToOpenFile(uuid(0), new Error('test error')),
|
||||
])('actions that should show notification: %o', async (action: AnyAction) => {
|
||||
const { toaster, saga } = createTestToasterSaga();
|
||||
|
||||
@@ -151,6 +152,7 @@ test.each([
|
||||
'test.file',
|
||||
new DOMException('test message', 'AbortError'),
|
||||
),
|
||||
editorDidFailToOpenFile(uuid(0), new EditorError('FileInUse', 'test error')),
|
||||
])('actions that should not show a notification: %o', async (action: AnyAction) => {
|
||||
const { toaster, saga } = createTestToasterSaga();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user