Files
pybricks-code/src/explorer/sagas.ts
T
David Lechner fc89c7b329 explorer: active file after duplicating
When duplicating a file users expect the file to be opened as in other
applications.

Fixes: https://github.com/pybricks/support/issues/1084
2023-05-26 11:13:31 -05:00

596 lines
18 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
import { fileOpen, fileSave } from 'browser-fs-access';
import JSZip from 'jszip';
import {
call,
getContext,
put,
race,
select,
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { alertsShowAlert } from '../alerts/actions';
import { zipFileExtension, zipFileMimeType } from '../app/constants';
import {
editorActivateFile,
editorCloseFile,
editorDidActivateFile,
editorDidCloseFile,
editorDidFailToActivateFile,
editorReplaceFile,
} from '../editor/actions';
import { EditorError } from '../editor/error';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import { FileStorageDb } from '../fileStorage';
import {
fileStorageCopyFile,
fileStorageDeleteFile,
fileStorageDidCopyFile,
fileStorageDidDeleteFile,
fileStorageDidDumpAllFiles,
fileStorageDidFailToCopyFile,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidFailToWriteFile,
fileStorageDidReadFile,
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 {
explorerArchiveAllFiles,
explorerCreateNewFile,
explorerDeleteFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
explorerDidDeleteFile,
explorerDidDuplicateFile,
explorerDidExportFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
explorerDidFailToDeleteFile,
explorerDidFailToDuplicateFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
explorerDidImportFiles,
explorerDidRenameFile,
explorerDuplicateFile,
explorerExportFile,
explorerImportFiles,
explorerRenameFile,
explorerUserActivateFile,
explorerUserDidActivateFile,
} from './actions';
import {
deleteFileAlertDidAccept,
deleteFileAlertDidCancel,
deleteFileAlertShow,
} from './deleteFileAlert/actions';
import {
duplicateFileDialogDidAccept,
duplicateFileDialogDidCancel,
duplicateFileDialogShow,
} from './duplicateFileDialog/actions';
import { ExplorerError } from './error';
import {
newFileWizardDidAccept,
newFileWizardDidCancel,
newFileWizardShow,
} from './newFileWizard/actions';
import {
renameFileDialogDidAccept,
renameFileDialogDidCancel,
renameFileDialogShow,
} from './renameFileDialog/actions';
import {
renameImportDialogDidAccept,
renameImportDialogDidCancel,
renameImportDialogShow,
} from './renameImportDialog/actions';
import {
ReplaceImportDialogAction,
replaceImportDialogDidAccept,
replaceImportDialogDidCancel,
replaceImportDialogShow,
} from './replaceImportDialog/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);
if (didDump.files.length === 0) {
throw new ExplorerError('NoFiles', 'no files in storage');
}
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()}${zipFileExtension}`;
yield* call(() =>
fileSave(zipData, {
id: 'pybricksCodeFileStorageArchive',
fileName,
extensions: [zipFileExtension],
mimeTypes: [zipFileMimeType],
// TODO: translate description
description: 'Zip Files',
}),
);
yield* put(explorerDidArchiveAllFiles());
} catch (err) {
const error = ensureError(err);
if (error instanceof DOMException && error.name === 'AbortError') {
// user canceled, don't show error message
} else if (error instanceof ExplorerError && error.name === 'NoFiles') {
yield* put(alertsShowAlert('explorer', 'noFilesToBackup'));
} else {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error,
}),
);
}
yield* put(explorerDidFailToArchiveAllFiles(error));
}
}
type ImportContext = {
rememberedAction?: ReplaceImportDialogAction;
};
function* importPythonFile(
sourceFileName: string,
sourceFileContents: string,
context: ImportContext,
): Generator {
const [baseName] = sourceFileName.split(pythonFileExtensionRegex);
let fileName = `${baseName}${pythonFileExtension}`;
const db = yield* getContext<FileStorageDb>('fileStorage');
const existingFiles = yield* call(() => db.metadata.toArray());
const result = validateFileName(
baseName,
pythonFileExtension,
existingFiles.map((f) => f.path),
);
let replace = false;
if (result === FileNameValidationResult.AlreadyExists) {
let action = context.rememberedAction;
if (action === undefined) {
yield* put(replaceImportDialogShow(sourceFileName));
const { accepted, cancelled } = yield* race({
accepted: take(replaceImportDialogDidAccept),
cancelled: take(replaceImportDialogDidCancel),
});
if (cancelled) {
return;
}
defined(accepted);
if (accepted.remember) {
context.rememberedAction = accepted.action;
}
action = accepted.action;
}
if (action === ReplaceImportDialogAction.Skip) {
return;
}
if (action === ReplaceImportDialogAction.Replace) {
replace = true;
}
}
if (result !== FileNameValidationResult.IsOk && !replace) {
yield* put(renameImportDialogShow(sourceFileName));
const { accepted, cancelled } = yield* race({
accepted: take(renameImportDialogDidAccept),
cancelled: take(renameImportDialogDidCancel),
});
if (cancelled) {
return;
}
defined(accepted);
fileName = accepted.newName;
}
const existingFileInfo = existingFiles.find((x) => x.path === fileName);
const openFileUuids = yield* select((s: RootState) => s.editor.openFileUuids);
// If the file is open, modify contents in the editor so preserve undo
// history, otherwise write directly to storage.
if (existingFileInfo && openFileUuids.includes(existingFileInfo.uuid)) {
yield* put(editorReplaceFile(existingFileInfo.uuid, sourceFileContents));
} else {
yield* put(fileStorageWriteFile(fileName, sourceFileContents));
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;
}
}
}
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,
},
{
mimeTypes: [zipFileMimeType],
extensions: [zipFileExtension],
// TODO: translate description
description: 'ZIP Files',
},
]),
);
const context: ImportContext = {};
for (const file of selectedFiles) {
switch (file.type) {
case '': // empty string means "could not be determined"
case pythonFileMimeType:
{
// getting the text now to catch possible error *before* user interaction
const text = yield* call(() => file.text());
yield* importPythonFile(file.name, text, context);
}
break;
case zipFileMimeType:
{
const zip = yield* call(() =>
JSZip.loadAsync(file.arrayBuffer()),
);
const zipFiles = zip.filter((_, f) =>
f.name.endsWith(pythonFileExtension),
);
if (zipFiles.length === 0) {
yield* put(alertsShowAlert('explorer', 'noPyFiles'));
break;
}
for (const zipFile of zipFiles) {
const text = yield* call(() => zipFile.async('text'));
yield* importPythonFile(zipFile.name, text, context);
}
}
break;
default:
throw new Error(
`'${file.name}' has unsupported file type: ${file.type}`,
);
}
}
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 { didWrite, didFailToWrite } = yield* race({
didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)),
didFailToWrite: take(
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
),
});
if (didFailToWrite) {
throw didFailToWrite.error;
}
defined(didWrite);
yield* put(editorActivateFile(didWrite.uuid));
yield* put(explorerDidCreateNewFile());
} catch (err) {
yield* put(explorerDidFailToCreateNewFile(ensureError(err)));
}
}
/**
* Connects user triggered action to editor module.
* @param action
*/
function* handleExplorerActivateFile(
action: ReturnType<typeof explorerUserActivateFile>,
): Generator {
yield* put(editorActivateFile(action.uuid));
const { didFailToActivate } = yield* race({
didActivate: take(editorDidActivateFile.when((a) => a.uuid === action.uuid)),
didFailToActivate: take(
editorDidFailToActivateFile.when((a) => a.uuid === action.uuid),
),
});
if (didFailToActivate) {
if (
didFailToActivate.error instanceof EditorError &&
didFailToActivate.error.name === 'FileInUse'
) {
yield* put(
alertsShowAlert('explorer', 'fileInUse', { fileName: action.fileName }),
);
} else {
yield* put(
alertsShowAlert('alerts', 'unexpectedError', {
error: didFailToActivate.error,
}),
);
}
}
yield* put(explorerUserDidActivateFile(action.fileName));
}
/** Connects user initiate duplicate file actions to the duplicate file dialog. */
function* handleExplorerDuplicateFile(
action: ReturnType<typeof explorerDuplicateFile>,
): Generator {
try {
yield* put(duplicateFileDialogShow(action.fileName));
const { didAccept, didCancel } = yield* race({
didAccept: take(duplicateFileDialogDidAccept),
didCancel: take(duplicateFileDialogDidCancel),
});
if (didCancel) {
throw new DOMException('user canceled', 'AbortError');
}
defined(didAccept);
// REVISIT: if editor is not flushed to storage right away, we would
// need to check for open editors here
yield* put(fileStorageCopyFile(action.fileName, didAccept.newName));
const { didCopy, didFailToCopy } = yield* race({
didCopy: take(
fileStorageDidCopyFile.when((a) => a.path === action.fileName),
),
didFailToCopy: take(
fileStorageDidFailToCopyFile.when((a) => a.path === action.fileName),
),
});
if (didFailToCopy) {
throw didFailToCopy.error;
}
defined(didCopy);
yield* put(editorActivateFile(didCopy.newUuid));
yield* put(explorerDidDuplicateFile(action.fileName));
} catch (err) {
yield* put(explorerDidFailToDuplicateFile(action.fileName, ensureError(err)));
}
}
/** 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 } = yield* race({
didAccept: take(deleteFileAlertDidAccept),
didCancel: take(deleteFileAlertDidCancel),
});
if (didCancel) {
throw new DOMException('user canceled', 'AbortError');
}
// at this point we know the user accepted
const openUuids = yield* select((s: RootState) => s.editor.openFileUuids);
// have to close editor before deleting, otherwise we get "in use" error
if (openUuids.includes(action.uuid)) {
yield* put(editorCloseFile(action.uuid));
yield* take(editorDidCloseFile.when((a) => a.uuid === action.uuid));
}
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(explorerUserActivateFile, handleExplorerActivateFile);
yield* takeEvery(explorerRenameFile, handleExplorerRenameFile);
yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile);
yield* takeEvery(explorerExportFile, handleExplorerExportFile);
yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile);
}