Files
pybricks-code/src/explorer/sagas.ts
T
David Lechner d238e68deb explorer: new delete alert dialog
This replaces the delete notification with a new alert dialog. This
makes for better keyboard interaction and forces the user to make a
decision before doing anything else.

Also fixes closing the editor before deleting the file.
2022-04-08 12:53:12 -05:00

407 lines
12 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { fileOpen, fileSave } from 'browser-fs-access';
import JSZip from 'jszip';
import {
call,
put,
race,
select,
take,
takeEvery,
takeLatest,
} from 'typed-redux-saga/macro';
import {
editorActivateFile,
editorCloseFile,
editorDidActivateFile,
editorDidCloseFile,
editorDidFailToActivateFile,
} from '../editor/actions';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import {
fileStorageDeleteFile,
fileStorageDidDeleteFile,
fileStorageDidDumpAllFiles,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidFailToWriteFile,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
fileStorageRenameFile,
fileStorageWriteFile,
} from '../fileStorage/actions';
import {
FileNameValidationResult,
pythonFileExtension,
pythonFileExtensionRegex,
pythonFileMimeType,
validateFileName,
} from '../pybricksMicropython/lib';
import { RootState } from '../reducers';
import { defined, ensureError, timestamp } from '../utils';
import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDidActivateFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidDeleteFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToDeleteFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
explorerDidImportFiles,
explorerDidRenameFile,
explorerExportFile,
explorerImportFiles,
explorerRenameFile,
} from './actions';
import {
deleteFileAlertDidAccept,
deleteFileAlertDidCancel,
deleteFileAlertShow,
} from './deleteFileAlert/actions';
import {
newFileWizardDidAccept,
newFileWizardDidCancel,
newFileWizardShow,
} from './newFileWizard/actions';
import {
renameFileDialogDidAccept,
renameFileDialogDidCancel,
renameFileDialogShow,
} from './renameFileDialog/actions';
function* handleExplorerArchiveAllFiles(): Generator {
try {
yield* put(fileStorageDumpAllFiles());
const { didDump, didFailToDump } = yield* race({
didDump: take(fileStorageDidDumpAllFiles),
didFailToDump: take(fileStorageDidFailToDumpAllFiles),
});
if (didFailToDump) {
throw didFailToDump.error;
}
defined(didDump);
const zip = new JSZip();
for (const f of didDump.files) {
yield* call(() => zip.file(f.path, f.contents));
}
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
const fileName = `pybricks-backup-${timestamp()}.zip`;
yield* call(() =>
fileSave(zipData, {
id: 'pybricksCodeFileStorageArchive',
fileName,
extensions: ['.zip'],
mimeTypes: ['application/zip'],
// TODO: translate description
description: 'Zip Files',
}),
);
yield* put(explorerDidArchiveAllFiles());
} catch (err) {
yield* put(explorerDidFailToArchiveAllFiles(ensureError(err)));
}
}
function* handleExplorerImportFiles(): Generator {
try {
const selectedFiles = yield* call(() =>
fileOpen({
id: 'pybricks-code-explorer-import',
mimeTypes: [pythonFileMimeType],
extensions: [pythonFileExtension],
// TODO: translate description
description: 'Python Files',
multiple: true,
excludeAcceptAllOption: true,
}),
);
for (const file of selectedFiles) {
// getting the text now to catch possible error *before* user interaction
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),
);
if (result != FileNameValidationResult.IsOk) {
// TODO: validate file name and allow user to rename or skip
console.error(
'skipping file',
file.name,
FileNameValidationResult[result],
);
continue;
}
const fileName = `${baseName}${pythonFileExtension}`;
yield* put(fileStorageWriteFile(fileName, text));
const { didFailToWrite } = yield* race({
didWrite: take(
fileStorageDidWriteFile.when((a) => a.path === fileName),
),
didFailToWrite: take(
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
),
});
if (didFailToWrite) {
throw didFailToWrite.error;
}
}
yield* put(explorerDidImportFiles());
} catch (err) {
yield* put(explorerDidFailToImportFiles(ensureError(err)));
}
}
function* handleExplorerCreateNewFile(): Generator {
try {
yield* put(newFileWizardShow());
const { didAccept, didCancel } = yield* race({
didAccept: take(newFileWizardDidAccept),
didCancel: take(newFileWizardDidCancel),
});
if (didCancel) {
throw new DOMException('user canceled', 'AbortError');
}
defined(didAccept);
const fileName = `${didAccept.fileName}${didAccept.fileExtension}`;
yield* put(
fileStorageWriteFile(
fileName,
getPybricksMicroPythonFileTemplate(didAccept.hubType) || '',
),
);
const { didFailToWrite } = yield* race({
didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)),
didFailToWrite: take(
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
),
});
if (didFailToWrite) {
throw didFailToWrite.error;
}
yield* put(editorActivateFile(fileName));
yield* put(explorerDidCreateNewFile());
} catch (err) {
yield* put(explorerDidFailToCreateNewFile(ensureError(err)));
}
}
/**
* Connects user triggered action to editor module.
* @param action
*/
function* handleExplorerActivateFile(
action: ReturnType<typeof explorerActivateFile>,
): Generator {
yield* put(editorActivateFile(action.fileName));
const { didActivate, didFailToActivate } = yield* race({
didActivate: take(
editorDidActivateFile.when((a) => a.fileName === action.fileName),
),
didFailToActivate: take(
editorDidFailToActivateFile.when((a) => a.fileName === action.fileName),
),
});
if (didFailToActivate) {
yield* put(
explorerDidFailToActivateFile(
didFailToActivate.fileName,
didFailToActivate.error,
),
);
return;
}
defined(didActivate);
yield* put(explorerDidActivateFile(didActivate.fileName));
}
/** Connects user initiate rename file actions to the rename file dialog. */
function* handleExplorerRenameFile(
action: ReturnType<typeof explorerRenameFile>,
): Generator {
yield* put(renameFileDialogShow(action.fileName));
const { accepted, canceled } = yield* race({
accepted: take(renameFileDialogDidAccept),
canceled: take(renameFileDialogDidCancel),
});
if (canceled) {
yield* put(explorerDidFailToRenameFile());
return;
}
defined(accepted);
yield* put(fileStorageRenameFile(action.fileName, accepted.newName));
const didRename = yield* race({
succeeded: take(
fileStorageDidRenameFile.when((a) => a.fileName === action.fileName),
),
failed: take(
fileStorageDidFailToRenameFile.when((a) => a.fileName === action.fileName),
),
});
if (didRename.failed) {
yield* put(explorerDidFailToRenameFile());
return;
}
yield* put(explorerDidRenameFile());
}
function* handleExplorerExportFile(
action: ReturnType<typeof explorerExportFile>,
): Generator {
try {
yield* put(fileStorageReadFile(action.fileName));
const { didRead, didFailToRead } = yield* race({
didRead: take(
fileStorageDidReadFile.when((a) => a.path === action.fileName),
),
didFailToRead: take(
fileStorageDidFailToReadFile.when((a) => a.path === action.fileName),
),
});
if (didFailToRead) {
throw didFailToRead.error;
}
defined(didRead);
const blob = new Blob([didRead.contents], { type: `${pythonFileMimeType}` });
yield* call(() =>
fileSave(blob, {
id: 'pybricksCodeFileStorageExport',
fileName: action.fileName,
extensions: [pythonFileExtension],
mimeTypes: [pythonFileMimeType],
// TODO: translate description
description: 'Python Files',
}),
);
yield* put(explorerDidExportFile(action.fileName));
} catch (err) {
yield* put(explorerDidFailToExportFile(action.fileName, ensureError(err)));
}
}
function* handleExplorerDeleteFile(action: ReturnType<typeof explorerDeleteFile>) {
try {
yield* put(deleteFileAlertShow(action.fileName));
const { didCancel, didRemove } = 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
yield* put(editorCloseFile(action.fileName));
yield* take(editorDidCloseFile.when((a) => a.fileName === action.fileName));
yield* put(fileStorageDeleteFile(action.fileName));
const { didFailToDelete } = yield* race({
didDelete: take(
fileStorageDidDeleteFile.when((a) => a.path === action.fileName),
),
didFailToDelete: take(
fileStorageDidFailToDeleteFile.when((a) => a.path === action.fileName),
),
});
if (didFailToDelete) {
throw didFailToDelete.error;
}
yield* put(explorerDidDeleteFile(action.fileName));
} catch (err) {
yield* put(explorerDidFailToDeleteFile(action.fileName, ensureError(err)));
}
}
export default function* (): Generator {
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
yield* takeEvery(explorerActivateFile, handleExplorerActivateFile);
// takeLatest should ensure that if we trigger a new rename before the
// previous one is finished, the old one will be canceled. We don't expect
// this to happen in practice though.
yield* takeLatest(explorerRenameFile, handleExplorerRenameFile);
yield* takeLatest(explorerExportFile, handleExplorerExportFile);
yield* takeLatest(explorerDeleteFile, handleExplorerDeleteFile);
}