rework alerts

This starts moving alerts to the same subsystem where they are relevant
instead of putting everything in notifications.

So far, only the explorer file in use error is handled like this.
This commit is contained in:
David Lechner
2022-05-20 19:25:18 -05:00
parent 4e88605b16
commit e7366c8afd
31 changed files with 649 additions and 138 deletions
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { CustomError } from '../utils/customError';
/** Specific errors for editor subsystem. */
export type EditorErrorName = 'FileInUse';
/** Error class for editor subsystem. */
export class EditorError extends CustomError<EditorErrorName> {}
+91 -51
View File
@@ -10,6 +10,7 @@ import {
fileStorageDidReadFile,
fileStorageReadFile,
} from '../fileStorage/actions';
import { acquireLock } from '../utils';
import {
editorActivateFile,
editorCloseFile,
@@ -21,6 +22,7 @@ import {
editorDidOpenFile,
editorOpenFile,
} from './actions';
import { EditorError, EditorErrorName } from './error';
import { ActiveFileHistoryManager, OpenFileInfo, OpenFileManager } from './lib';
import editor from './sagas';
@@ -51,6 +53,22 @@ it('should activate files from storage', async () => {
await saga.end();
});
/**
* Asymmetric matcher for matching errors by name while ignoring the message.
* @param name The name to match.
* @returns An asymmetric matcher cast to an EditorError so that it can be
* passed to action functions.
*/
function expectEditorError(name: EditorErrorName): EditorError {
const matcher: jest.AsymmetricMatcher & Record<string, unknown> = {
$$typeof: Symbol.for('jest.asymmetricMatcher'),
asymmetricMatch: (other) => other instanceof EditorError && other.name === name,
toAsymmetricMatcher: () => `[EditorError: ${name}]`,
};
return matcher as unknown as EditorError;
}
describe('per-editor sagas', () => {
let saga: AsyncSaga;
let monacoEditor: monaco.editor.IStandaloneCodeEditor;
@@ -70,70 +88,92 @@ describe('per-editor sagas', () => {
});
describe('handleEditorOpenFile', () => {
beforeEach(async () => {
jest.spyOn(OpenFileManager.prototype, 'add');
jest.spyOn(OpenFileManager.prototype, 'remove');
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
fileStorageReadFile('test.file'),
it('should fail if file is already in use', async () => {
const releaseLock = await acquireLock(
'pybricks.editor+pybricksCode:test.file',
);
expect(releaseLock).toBeDefined();
try {
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
editorDidFailToOpenFile(
'test.file',
expectEditorError('FileInUse'),
),
);
} finally {
await releaseLock?.();
}
});
it('should propagate error from fileStorageReadFile', async () => {
const testError = new Error('test error');
saga.put(fileStorageDidFailToReadFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
editorDidFailToOpenFile('test.file', testError),
);
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
});
describe('read succeeded', () => {
let model: monaco.editor.ITextModel;
describe('not already in use', () => {
beforeEach(async () => {
monaco.editor.onDidCreateModel((m) => (model = m));
saga.put(fileStorageDidReadFile('test.file', ''));
expect(model).toBeDefined();
jest.spyOn(OpenFileManager.prototype, 'add');
jest.spyOn(OpenFileManager.prototype, 'remove');
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
editorDidOpenFile('test.file'),
fileStorageReadFile('test.file'),
);
expect(OpenFileManager.prototype.add).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).not.toHaveBeenCalled();
});
it('should close file if task is canceled', async () => {
jest.spyOn(model, 'dispose');
it('should propagate error from fileStorageReadFile', async () => {
const testError = new Error('test error');
saga.cancel();
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
// editorDidCloseFile is not called since we did not put editorCloseFile
});
it('should close when requested', async () => {
jest.spyOn(model, 'dispose');
saga.put(editorCloseFile('test.file'));
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
saga.put(fileStorageDidFailToReadFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
editorDidCloseFile('test.file'),
editorDidFailToOpenFile('test.file', testError),
);
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
});
describe('read succeeded', () => {
let model: monaco.editor.ITextModel;
beforeEach(async () => {
monaco.editor.onDidCreateModel((m) => (model = m));
saga.put(fileStorageDidReadFile('test.file', ''));
expect(model).toBeDefined();
await expect(saga.take()).resolves.toEqual(
editorDidOpenFile('test.file'),
);
expect(OpenFileManager.prototype.add).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).not.toHaveBeenCalled();
});
it('should close file if task is canceled', async () => {
jest.spyOn(model, 'dispose');
saga.cancel();
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
// editorDidCloseFile is not called since we did not put editorCloseFile
});
it('should close when requested', async () => {
jest.spyOn(model, 'dispose');
saga.put(editorCloseFile('test.file'));
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
editorDidCloseFile('test.file'),
);
});
});
});
});
+17 -2
View File
@@ -5,6 +5,7 @@ import { monaco } from 'react-monaco-editor';
import { EventChannel, buffers, eventChannel } from 'redux-saga';
import {
SagaGenerator,
call,
delay,
fork,
getContext,
@@ -22,7 +23,7 @@ import {
fileStorageWriteFile,
} from '../fileStorage/actions';
import { RootState } from '../reducers';
import { defined, ensureError } from '../utils';
import { acquireLock, defined, ensureError } from '../utils';
import {
editorActivateFile,
editorCloseFile,
@@ -36,6 +37,7 @@ import {
editorGetValueResponse,
editorOpenFile,
} from './actions';
import { EditorError } from './error';
import { ActiveFileHistoryManager, OpenFileManager } from './lib';
import { pybricksMicroPythonId } from './pybricksMicroPython';
@@ -93,7 +95,7 @@ function* handleEditorOpenFile(
let closeRequested = false;
try {
const defer: Array<() => void> = [];
const defer: Array<() => void | Promise<void>> = [];
try {
const modelUri = monaco.Uri.from({
@@ -101,6 +103,19 @@ function* handleEditorOpenFile(
path: action.fileName,
});
const releaseLock = yield* call(() =>
acquireLock(`pybricks.editor+${modelUri}`),
);
if (!releaseLock) {
throw new EditorError(
'FileInUse',
'the file is already open in another editor',
);
}
defer.push(releaseLock);
yield* put(fileStorageReadFile(modelUri.fsPath));
const { didRead, didFailToRead } = yield* race({