Show save/execution error dialogs on desktop #264
This commit introduces system-native error dialogs on desktop application for code save or execution failures, addressing user confusion described in issue #264. This commit adds informative feedback when script execution or saving fails. Changes: - Implement support for system-native error dialogs. - Refactor `CodeRunner` and `Dialog` interfaces and their implementations to improve error handling and provide better type safety. - Introduce structured error handling, allowing UI to display detailed error messages. - Replace error throwing with an error object interface for controlled handling. This ensures that errors are propagated to the renderer process without being limited by Electron's error object serialization limitations as detailed in electron/electron#24427. - Add logging for dialog actions to aid in troubleshooting. - Rename `fileName` to `defaultFilename` in `saveFile` functions to clarify its purpose. - Centralize message assertion in `LoggerStub` for consistency. - Introduce `expectTrue` in tests for clearer boolean assertions. - Standardize `filename` usage across the codebase. - Enhance existing test names and organization for clarity. - Update related documentation.
This commit is contained in:
@@ -1,40 +1,116 @@
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { SystemOperations } from '../../System/SystemOperations';
|
||||
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations';
|
||||
import { ScriptDirectoryProvider } from './ScriptDirectoryProvider';
|
||||
import { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider';
|
||||
|
||||
export const ExecutionSubdirectory = 'runs';
|
||||
|
||||
/**
|
||||
* Provides a dedicated directory for script execution.
|
||||
* Benefits of using a persistent directory:
|
||||
* - Antivirus Exclusions: Easier antivirus configuration.
|
||||
* - Auditability: Stores script execution history for troubleshooting.
|
||||
* - Reliability: Avoids issues with directory clean-ups during execution,
|
||||
* seen in Windows Pro Azure VMs when stored on Windows temporary directory.
|
||||
*/
|
||||
export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||
constructor(
|
||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
|
||||
async provideScriptDirectory(): Promise<string> {
|
||||
const scriptsDirectory = this.system.location.combinePaths(
|
||||
/*
|
||||
Switched from temporary to persistent directory for script storage for improved reliability.
|
||||
|
||||
Temporary directories in some environments, such certain Windows Pro Azure VMs, showed
|
||||
issues where scripts were interrupted due to directory cleanup during script execution.
|
||||
This was observed with system temp directories (e.g., `%LOCALAPPDATA%\Temp`).
|
||||
|
||||
Persistent directories offer better stability during long executions and aid in auditability
|
||||
and troubleshooting.
|
||||
*/
|
||||
this.system.operatingSystem.getUserDataDirectory(),
|
||||
ExecutionSubdirectory,
|
||||
);
|
||||
this.logger.info(`Attempting to create script directory at path: ${scriptsDirectory}`);
|
||||
try {
|
||||
await this.system.fileSystem.createDirectory(scriptsDirectory, true);
|
||||
this.logger.info(`Script directory successfully created at: ${scriptsDirectory}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating script directory at ${scriptsDirectory}: ${error.message}`, error);
|
||||
throw error;
|
||||
public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
|
||||
const {
|
||||
success: isPathConstructed,
|
||||
error: pathConstructionError,
|
||||
directoryPath,
|
||||
} = this.constructScriptDirectoryPath();
|
||||
if (!isPathConstructed) {
|
||||
return {
|
||||
success: false,
|
||||
error: pathConstructionError,
|
||||
};
|
||||
}
|
||||
return scriptsDirectory;
|
||||
const {
|
||||
success: isDirectoryCreated,
|
||||
error: directoryCreationError,
|
||||
} = await this.createDirectory(directoryPath);
|
||||
if (!isDirectoryCreated) {
|
||||
return {
|
||||
success: false,
|
||||
error: directoryCreationError,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
directoryAbsolutePath: directoryPath,
|
||||
};
|
||||
}
|
||||
|
||||
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
|
||||
try {
|
||||
this.logger.info(`Attempting to create script directory at path: ${directoryPath}`);
|
||||
await this.system.fileSystem.createDirectory(directoryPath, true);
|
||||
this.logger.info(`Script directory successfully created at: ${directoryPath}`);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'DirectoryCreationError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome {
|
||||
try {
|
||||
const parentDirectory = this.system.operatingSystem.getUserDataDirectory();
|
||||
const scriptDirectory = this.system.location.combinePaths(
|
||||
parentDirectory,
|
||||
ExecutionSubdirectory,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
directoryPath: scriptDirectory,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'DirectoryCreationError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleException(
|
||||
exception: Error,
|
||||
errorType: CodeRunErrorType,
|
||||
): CodeRunError {
|
||||
const errorMessage = 'Error during script directory creation';
|
||||
this.logger.error(errorType, errorMessage, exception);
|
||||
return {
|
||||
type: errorType,
|
||||
message: `${errorMessage}: ${exception.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type DirectoryPathConstructionOutcome = {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
readonly directoryPath?: undefined;
|
||||
} | {
|
||||
readonly success: true;
|
||||
readonly directoryPath: string;
|
||||
readonly error?: undefined;
|
||||
};
|
||||
|
||||
type DirectoryPathCreationOutcome = {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
} | {
|
||||
readonly success: true;
|
||||
readonly error?: undefined;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||
|
||||
export interface ScriptDirectoryProvider {
|
||||
provideScriptDirectory(): Promise<string>;
|
||||
provideScriptDirectory(): Promise<ScriptDirectoryOutcome>;
|
||||
}
|
||||
|
||||
export type ScriptDirectoryOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
|
||||
|
||||
interface ScriptDirectoryCreationStatus {
|
||||
readonly success: boolean;
|
||||
readonly directoryAbsolutePath?: string;
|
||||
readonly error?: CodeRunError;
|
||||
}
|
||||
|
||||
interface SuccessfulDirectoryCreation extends ScriptDirectoryCreationStatus {
|
||||
readonly success: true;
|
||||
readonly directoryAbsolutePath: string;
|
||||
}
|
||||
|
||||
interface FailedDirectoryCreation extends ScriptDirectoryCreationStatus {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ScriptFileNameParts } from '../ScriptFileCreator';
|
||||
import { ScriptFilenameParts } from '../ScriptFileCreator';
|
||||
|
||||
export interface FilenameGenerator {
|
||||
generateFilename(scriptFileNameParts: ScriptFileNameParts): string;
|
||||
generateFilename(scriptFilenameParts: ScriptFilenameParts): string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ScriptFileNameParts } from '../ScriptFileCreator';
|
||||
import { ScriptFilenameParts } from '../ScriptFileCreator';
|
||||
import { FilenameGenerator } from './FilenameGenerator';
|
||||
|
||||
export class TimestampedFilenameGenerator implements FilenameGenerator {
|
||||
public generateFilename(
|
||||
scriptFileNameParts: ScriptFileNameParts,
|
||||
scriptFilenameParts: ScriptFilenameParts,
|
||||
date = new Date(),
|
||||
): string {
|
||||
validateScriptFileNameParts(scriptFileNameParts);
|
||||
const baseFileName = `${createTimeStampForFile(date)}-${scriptFileNameParts.scriptName}`;
|
||||
return scriptFileNameParts.scriptFileExtension ? `${baseFileName}.${scriptFileNameParts.scriptFileExtension}` : baseFileName;
|
||||
validateScriptFilenameParts(scriptFilenameParts);
|
||||
const baseFilename = `${createTimeStampForFile(date)}-${scriptFilenameParts.scriptName}`;
|
||||
return scriptFilenameParts.scriptFileExtension ? `${baseFilename}.${scriptFilenameParts.scriptFileExtension}` : baseFilename;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ function createTimeStampForFile(date: Date): string {
|
||||
.replace(/\..+/, '');
|
||||
}
|
||||
|
||||
function validateScriptFileNameParts(scriptFileNameParts: ScriptFileNameParts) {
|
||||
if (!scriptFileNameParts.scriptName) {
|
||||
function validateScriptFilenameParts(scriptFilenameParts: ScriptFilenameParts) {
|
||||
if (!scriptFilenameParts.scriptName) {
|
||||
throw new Error('Script name is required but not provided.');
|
||||
}
|
||||
if (scriptFileNameParts.scriptFileExtension?.startsWith('.')) {
|
||||
if (scriptFilenameParts.scriptFileExtension?.startsWith('.')) {
|
||||
throw new Error('File extension should not start with a dot.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { SystemOperations } from '../System/SystemOperations';
|
||||
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
|
||||
import { FilenameGenerator } from './Filename/FilenameGenerator';
|
||||
import { ScriptFileNameParts, ScriptFileCreator } from './ScriptFileCreator';
|
||||
import { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
|
||||
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
|
||||
import { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
|
||||
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
|
||||
@@ -18,23 +19,99 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
|
||||
|
||||
public async createScriptFile(
|
||||
contents: string,
|
||||
scriptFileNameParts: ScriptFileNameParts,
|
||||
): Promise<string> {
|
||||
const filePath = await this.provideFilePath(scriptFileNameParts);
|
||||
await this.createFile(filePath, contents);
|
||||
return filePath;
|
||||
scriptFilenameParts: ScriptFilenameParts,
|
||||
): Promise<ScriptFileCreationOutcome> {
|
||||
const {
|
||||
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
|
||||
} = await this.directoryProvider.provideScriptDirectory();
|
||||
if (!isDirectoryCreated) {
|
||||
return createFailure(directoryCreationError);
|
||||
}
|
||||
const {
|
||||
success: isFilePathConstructed, error: filePathGenerationError, filePath,
|
||||
} = this.constructFilePath(scriptFilenameParts, directoryAbsolutePath);
|
||||
if (!isFilePathConstructed) {
|
||||
return createFailure(filePathGenerationError);
|
||||
}
|
||||
const {
|
||||
success: isFileCreated, error: fileCreationError,
|
||||
} = await this.writeFile(filePath, contents);
|
||||
if (!isFileCreated) {
|
||||
return createFailure(fileCreationError);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
scriptFileAbsolutePath: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
private async provideFilePath(scriptFileNameParts: ScriptFileNameParts): Promise<string> {
|
||||
const filename = this.filenameGenerator.generateFilename(scriptFileNameParts);
|
||||
const directoryPath = await this.directoryProvider.provideScriptDirectory();
|
||||
const filePath = this.system.location.combinePaths(directoryPath, filename);
|
||||
return filePath;
|
||||
private constructFilePath(
|
||||
scriptFilenameParts: ScriptFilenameParts,
|
||||
directoryPath: string,
|
||||
): FilePathConstructionOutcome {
|
||||
try {
|
||||
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
|
||||
const filePath = this.system.location.combinePaths(directoryPath, filename);
|
||||
return { success: true, filePath };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'FilePathGenerationError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createFile(filePath: string, contents: string): Promise<void> {
|
||||
this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`);
|
||||
await this.system.fileSystem.writeToFile(filePath, contents);
|
||||
this.logger.info(`File created successfully at ${filePath}`);
|
||||
private async writeFile(
|
||||
filePath: string,
|
||||
contents: string,
|
||||
): Promise<FileWriteOutcome> {
|
||||
try {
|
||||
this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`);
|
||||
await this.system.fileSystem.writeToFile(filePath, contents);
|
||||
this.logger.info(`File created successfully at ${filePath}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'FileWriteError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleException(
|
||||
exception: Error,
|
||||
errorType: CodeRunErrorType,
|
||||
): CodeRunError {
|
||||
const errorMessage = 'Error during script file operation';
|
||||
this.logger.error(errorType, errorMessage, exception);
|
||||
return {
|
||||
type: errorType,
|
||||
message: `${errorMessage}: ${exception.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createFailure(error: CodeRunError): ScriptFileCreationOutcome {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
type FileWriteOutcome = {
|
||||
readonly success: true;
|
||||
readonly error?: undefined;
|
||||
} | {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
};
|
||||
|
||||
type FilePathConstructionOutcome = {
|
||||
readonly success: true;
|
||||
readonly filePath: string;
|
||||
readonly error?: undefined;
|
||||
} | {
|
||||
readonly success: false;
|
||||
readonly filePath?: undefined;
|
||||
readonly error: CodeRunError;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||
|
||||
export interface ScriptFileCreator {
|
||||
createScriptFile(
|
||||
contents: string,
|
||||
scriptFileNameParts: ScriptFileNameParts,
|
||||
): Promise<string>;
|
||||
scriptFilenameParts: ScriptFilenameParts,
|
||||
): Promise<ScriptFileCreationOutcome>;
|
||||
}
|
||||
|
||||
export interface ScriptFileNameParts {
|
||||
export interface ScriptFilenameParts {
|
||||
readonly scriptName: string;
|
||||
readonly scriptFileExtension: string | undefined;
|
||||
}
|
||||
|
||||
export type ScriptFileCreationOutcome = SuccessfulScriptCreation | FailedScriptCreation;
|
||||
|
||||
interface ScriptFileCreationStatus {
|
||||
readonly success: boolean;
|
||||
readonly error?: CodeRunError;
|
||||
readonly scriptFileAbsolutePath?: string;
|
||||
}
|
||||
|
||||
interface SuccessfulScriptCreation extends ScriptFileCreationStatus {
|
||||
readonly success: true;
|
||||
readonly scriptFileAbsolutePath: string;
|
||||
}
|
||||
|
||||
interface FailedScriptCreation extends ScriptFileCreationStatus {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||
|
||||
export interface ScriptFileExecutor {
|
||||
executeScriptFile(filePath: string): Promise<void>;
|
||||
executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome>;
|
||||
}
|
||||
|
||||
export type ScriptFileExecutionOutcome = SuccessfulScriptFileExecution | FailedScriptFileExecution;
|
||||
|
||||
interface ScriptFileExecutionStatus {
|
||||
readonly success: boolean;
|
||||
readonly error?: CodeRunError;
|
||||
}
|
||||
|
||||
interface SuccessfulScriptFileExecution extends ScriptFileExecutionStatus {
|
||||
readonly success: true;
|
||||
readonly error?: undefined;
|
||||
}
|
||||
|
||||
export interface FailedScriptFileExecution extends ScriptFileExecutionStatus {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import { ScriptFileExecutor } from './ScriptFileExecutor';
|
||||
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { isString } from '@/TypeHelpers';
|
||||
import { FailedScriptFileExecution, ScriptFileExecutionOutcome, ScriptFileExecutor } from './ScriptFileExecutor';
|
||||
|
||||
export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
|
||||
constructor(
|
||||
@@ -14,38 +16,77 @@ export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
|
||||
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
|
||||
) { }
|
||||
|
||||
public async executeScriptFile(filePath: string): Promise<void> {
|
||||
public async executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||
const { os } = this.environment;
|
||||
if (os === undefined) {
|
||||
throw new Error('Unknown operating system');
|
||||
return this.handleError('UnsupportedOperatingSystem', 'Operating system could not be identified from environment.');
|
||||
}
|
||||
await this.setFileExecutablePermissions(filePath);
|
||||
await this.runFileWithRunner(filePath, os);
|
||||
const filePermissionsResult = await this.setFileExecutablePermissions(filePath);
|
||||
if (!filePermissionsResult.success) {
|
||||
return filePermissionsResult;
|
||||
}
|
||||
const scriptExecutionResult = await this.runFileWithRunner(filePath, os);
|
||||
if (!scriptExecutionResult.success) {
|
||||
return scriptExecutionResult;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async setFileExecutablePermissions(filePath: string): Promise<void> {
|
||||
private async setFileExecutablePermissions(
|
||||
filePath: string,
|
||||
): Promise<ScriptFileExecutionOutcome> {
|
||||
/*
|
||||
This is required on macOS and Linux otherwise the terminal emulators will refuse to
|
||||
execute the script. It's not needed on Windows.
|
||||
*/
|
||||
this.logger.info(`Setting execution permissions for file at ${filePath}`);
|
||||
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
||||
this.logger.info(`Execution permissions set successfully for ${filePath}`);
|
||||
try {
|
||||
this.logger.info(`Setting execution permissions for file at ${filePath}`);
|
||||
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
||||
this.logger.info(`Execution permissions set successfully for ${filePath}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return this.handleError('FileExecutionError', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async runFileWithRunner(filePath: string, os: OperatingSystem): Promise<void> {
|
||||
private async runFileWithRunner(
|
||||
filePath: string,
|
||||
os: OperatingSystem,
|
||||
): Promise<ScriptFileExecutionOutcome> {
|
||||
this.logger.info(`Executing script file: ${filePath} on ${OperatingSystem[os]}.`);
|
||||
const runner = TerminalRunners[os];
|
||||
if (!runner) {
|
||||
throw new Error(`Unsupported operating system: ${OperatingSystem[os]}`);
|
||||
return this.handleError('UnsupportedOperatingSystem', `Unsupported operating system: ${OperatingSystem[os]}`);
|
||||
}
|
||||
const context: TerminalExecutionContext = {
|
||||
scriptFilePath: filePath,
|
||||
commandOps: this.system.command,
|
||||
logger: this.logger,
|
||||
};
|
||||
await runner(context);
|
||||
this.logger.info('Command script file successfully.');
|
||||
try {
|
||||
await runner(context);
|
||||
this.logger.info('Command script file successfully.');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return this.handleError('FileExecutionError', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(
|
||||
type: CodeRunErrorType,
|
||||
error: Error | string,
|
||||
): FailedScriptFileExecution {
|
||||
const errorMessage = 'Error during script file execution';
|
||||
this.logger.error([type, errorMessage, ...(error ? [error] : [])]);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type,
|
||||
message: `${errorMessage}: ${isString(error) ? error : errorMessage}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
|
||||
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
||||
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
|
||||
import {
|
||||
CodeRunError, CodeRunOutcome, CodeRunner, FailedCodeRun,
|
||||
} from '@/application/CodeRunner/CodeRunner';
|
||||
import { ElectronLogger } from '../Log/ElectronLogger';
|
||||
import { ScriptFileExecutor } from './Execution/ScriptFileExecutor';
|
||||
import { ScriptFileCreator } from './Creation/ScriptFileCreator';
|
||||
@@ -18,18 +20,38 @@ export class ScriptFileCodeRunner implements CodeRunner {
|
||||
public async runCode(
|
||||
code: string,
|
||||
fileExtension: string,
|
||||
): Promise<void> {
|
||||
): Promise<CodeRunOutcome> {
|
||||
this.logger.info('Initiating script running process.');
|
||||
try {
|
||||
const scriptFilePath = await this.scriptFileCreator.createScriptFile(code, {
|
||||
scriptName: ScriptFileName,
|
||||
scriptFileExtension: fileExtension,
|
||||
});
|
||||
await this.scriptFileExecutor.executeScriptFile(scriptFilePath);
|
||||
this.logger.info(`Successfully ran script at ${scriptFilePath}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error running script: ${error.message}`, error);
|
||||
throw error;
|
||||
const {
|
||||
success: isFileCreated, scriptFileAbsolutePath, error: fileCreationError,
|
||||
} = await this.scriptFileCreator.createScriptFile(code, {
|
||||
scriptName: ScriptFilename,
|
||||
scriptFileExtension: fileExtension,
|
||||
});
|
||||
if (!isFileCreated) {
|
||||
return createFailure(fileCreationError);
|
||||
}
|
||||
const {
|
||||
success: isFileSuccessfullyExecuted,
|
||||
error: fileExecutionError,
|
||||
} = await this.scriptFileExecutor.executeScriptFile(
|
||||
scriptFileAbsolutePath,
|
||||
);
|
||||
if (!isFileSuccessfullyExecuted) {
|
||||
return createFailure(fileExecutionError);
|
||||
}
|
||||
this.logger.info(`Successfully ran script at ${scriptFileAbsolutePath}`);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createFailure(
|
||||
error: CodeRunError,
|
||||
): FailedCodeRun {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
||||
import { Dialog, FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { FileSaverDialog } from './FileSaverDialog';
|
||||
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
|
||||
|
||||
export class BrowserDialog implements Dialog {
|
||||
constructor(private readonly saveFileDialog: BrowserSaveFileDialog = new FileSaverDialog()) {
|
||||
constructor(
|
||||
private readonly window: WindowDialogAccessor = globalThis.window,
|
||||
private readonly saveFileDialog: BrowserSaveFileDialog = new FileSaverDialog(),
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public showError(title: string, message: string): void {
|
||||
this.window.alert(`❌ ${title}\n\n${message}`);
|
||||
}
|
||||
|
||||
public saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
defaultFilename: string,
|
||||
type: FileType,
|
||||
): Promise<void> {
|
||||
): Promise<SaveFileOutcome> {
|
||||
return Promise.resolve(
|
||||
this.saveFileDialog.saveFile(fileContents, fileName, type),
|
||||
this.saveFileDialog.saveFile(fileContents, defaultFilename, type),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WindowDialogAccessor {
|
||||
readonly alert: typeof window.alert;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
|
||||
export interface BrowserSaveFileDialog {
|
||||
saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
defaultFilename: string,
|
||||
fileType: FileType,
|
||||
): void;
|
||||
): SaveFileOutcome;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fileSaver from 'file-saver';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
|
||||
|
||||
export type SaveAsFunction = (data: Blob, filename?: string) => void;
|
||||
@@ -14,17 +14,20 @@ export class FileSaverDialog implements BrowserSaveFileDialog {
|
||||
|
||||
public saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
defaultFilename: string,
|
||||
fileType: FileType,
|
||||
): void {
|
||||
): SaveFileOutcome {
|
||||
const mimeType = MimeTypes[fileType];
|
||||
this.saveBlob(fileContents, mimeType, fileName);
|
||||
this.saveBlob(fileContents, mimeType, defaultFilename);
|
||||
return {
|
||||
success: true, // Exceptions are handled internally
|
||||
};
|
||||
}
|
||||
|
||||
private saveBlob(file: BlobPart, mimeType: string, fileName: string): void {
|
||||
private saveBlob(file: BlobPart, mimeType: string, defaultFilename: string): void {
|
||||
try {
|
||||
const blob = new Blob([file], { type: mimeType });
|
||||
this.fileSaverSaveAs(blob, fileName);
|
||||
this.fileSaverSaveAs(blob, defaultFilename);
|
||||
} catch (e) {
|
||||
this.windowOpen(`data:${mimeType},${encodeURIComponent(file.toString())}`, '_blank', '');
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
||||
import { dialog } from 'electron/main';
|
||||
import { Dialog, FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { NodeElectronSaveFileDialog } from './NodeElectronSaveFileDialog';
|
||||
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
||||
|
||||
export class ElectronDialog implements Dialog {
|
||||
constructor(
|
||||
private readonly fileSaveDialog: ElectronSaveFileDialog = new NodeElectronSaveFileDialog(),
|
||||
private readonly saveFileDialog: ElectronSaveFileDialog = new NodeElectronSaveFileDialog(),
|
||||
private readonly electron: ElectronDialogAccessor = {
|
||||
showErrorBox: dialog.showErrorBox.bind(dialog),
|
||||
},
|
||||
) { }
|
||||
|
||||
public async saveFile(
|
||||
public saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
defaultFilename: string,
|
||||
type: FileType,
|
||||
): Promise<void> {
|
||||
await this.fileSaveDialog.saveFile(fileContents, fileName, type);
|
||||
): Promise<SaveFileOutcome> {
|
||||
return this.saveFileDialog.saveFile(fileContents, defaultFilename, type);
|
||||
}
|
||||
|
||||
public showError(title: string, message: string): void {
|
||||
this.electron.showErrorBox(title, message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronDialogAccessor {
|
||||
readonly showErrorBox: typeof dialog.showErrorBox;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
|
||||
export interface ElectronSaveFileDialog {
|
||||
saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
defaultFilename: string,
|
||||
type: FileType,
|
||||
): Promise<void>;
|
||||
): Promise<SaveFileOutcome>;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,11 @@ import { writeFile } from 'node:fs/promises';
|
||||
import { app, dialog } from 'electron/main';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import {
|
||||
FileType, SaveFileError, SaveFileErrorType, SaveFileOutcome,
|
||||
} from '@/presentation/common/Dialog';
|
||||
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
||||
|
||||
export interface ElectronFileDialogOperations {
|
||||
getUserDownloadsPath(): string;
|
||||
showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;
|
||||
}
|
||||
|
||||
export interface NodeFileOperations {
|
||||
readonly join: typeof join;
|
||||
writeFile(file: string, data: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
@@ -31,44 +23,123 @@ export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
|
||||
|
||||
public async saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
defaultFilename: string,
|
||||
type: FileType,
|
||||
): Promise<void> {
|
||||
const userSelectedFilePath = await this.showSaveFileDialog(fileName, type);
|
||||
if (!userSelectedFilePath) {
|
||||
this.logger.info(`File save cancelled by user: ${fileName}`);
|
||||
return;
|
||||
): Promise<SaveFileOutcome> {
|
||||
const {
|
||||
success: isPathConstructed,
|
||||
filePath: defaultFilePath,
|
||||
error: pathConstructionError,
|
||||
} = this.constructDefaultFilePath(defaultFilename);
|
||||
if (!isPathConstructed) {
|
||||
return { success: false, error: pathConstructionError };
|
||||
}
|
||||
await this.writeFile(userSelectedFilePath, fileContents);
|
||||
const fileDialog = await this.showSaveFileDialog(defaultFilename, defaultFilePath, type);
|
||||
if (!fileDialog.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: fileDialog.error,
|
||||
};
|
||||
}
|
||||
if (fileDialog.canceled) {
|
||||
this.logger.info(`File save cancelled by user: ${defaultFilename}`);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
const result = await this.writeFile(fileDialog.filePath, fileContents);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async writeFile(filePath: string, fileContents: string): Promise<void> {
|
||||
private async writeFile(
|
||||
filePath: string,
|
||||
fileContents: string,
|
||||
): Promise<SaveFileOutcome> {
|
||||
try {
|
||||
this.logger.info(`Saving file: ${filePath}`);
|
||||
await this.node.writeFile(filePath, fileContents);
|
||||
this.logger.info(`File saved: ${filePath}`);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving file: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'FileCreationError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async showSaveFileDialog(fileName: string, type: FileType): Promise<string | undefined> {
|
||||
const downloadsFolder = this.electron.getUserDownloadsPath();
|
||||
const defaultFilePath = this.node.join(downloadsFolder, fileName);
|
||||
const dialogResult = await this.electron.showSaveDialog({
|
||||
title: fileName,
|
||||
defaultPath: defaultFilePath,
|
||||
filters: getDialogFileFilters(type),
|
||||
properties: [
|
||||
'createDirectory', // Enables directory creation on macOS.
|
||||
'showOverwriteConfirmation', // Shows overwrite confirmation on Linux.
|
||||
],
|
||||
});
|
||||
if (dialogResult.canceled) {
|
||||
return undefined;
|
||||
private async showSaveFileDialog(
|
||||
defaultFilename: string,
|
||||
defaultFilePath: string,
|
||||
type: FileType,
|
||||
): Promise<SaveDialogOutcome> {
|
||||
try {
|
||||
const dialogResult = await this.electron.showSaveDialog({
|
||||
title: defaultFilename,
|
||||
defaultPath: defaultFilePath,
|
||||
filters: getDialogFileFilters(type),
|
||||
properties: [
|
||||
'createDirectory', // Enables directory creation on macOS.
|
||||
'showOverwriteConfirmation', // Shows overwrite confirmation on Linux.
|
||||
],
|
||||
});
|
||||
if (dialogResult.canceled) {
|
||||
return { success: true, canceled: true };
|
||||
}
|
||||
if (!dialogResult.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: { type: 'DialogDisplayError', message: 'Unexpected Error: File path is undefined after save dialog completion.' },
|
||||
};
|
||||
}
|
||||
return { success: true, filePath: dialogResult.filePath };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'DialogDisplayError'),
|
||||
};
|
||||
}
|
||||
return dialogResult.filePath;
|
||||
}
|
||||
|
||||
private constructDefaultFilePath(defaultFilename: string): DefaultFilePathConstructionOutcome {
|
||||
try {
|
||||
const downloadsFolder = this.electron.getUserDownloadsPath();
|
||||
const defaultFilePath = this.node.join(downloadsFolder, defaultFilename);
|
||||
return {
|
||||
success: true,
|
||||
filePath: defaultFilePath,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(err, 'DialogDisplayError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleException(
|
||||
exception: Error,
|
||||
errorType: SaveFileErrorType,
|
||||
): SaveFileError {
|
||||
const errorMessage = 'Error during saving script file.';
|
||||
this.logger.error(errorType, errorMessage, exception);
|
||||
return {
|
||||
type: errorType,
|
||||
message: `${errorMessage}: ${exception.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronFileDialogOperations {
|
||||
getUserDownloadsPath(): string;
|
||||
showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;
|
||||
}
|
||||
|
||||
export interface NodeFileOperations {
|
||||
readonly join: typeof join;
|
||||
writeFile(file: string, data: string): Promise<void>;
|
||||
}
|
||||
|
||||
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {
|
||||
@@ -96,3 +167,12 @@ const FileTypeSpecificFilters: Record<FileType, Electron.FileFilter[]> = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type SaveDialogOutcome =
|
||||
| { readonly success: true; readonly filePath: string; readonly canceled?: false }
|
||||
| { readonly success: true; readonly canceled: true }
|
||||
| { readonly success: false; readonly error: SaveFileError; readonly canceled?: false };
|
||||
|
||||
type DefaultFilePathConstructionOutcome =
|
||||
| { readonly success: true; readonly filePath: string; readonly error?: undefined; }
|
||||
| { readonly success: false; readonly filePath?: undefined; readonly error: SaveFileError; };
|
||||
|
||||
36
src/infrastructure/Dialog/LoggingDialogDecorator.ts
Normal file
36
src/infrastructure/Dialog/LoggingDialogDecorator.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
||||
|
||||
export function decorateWithLogging(
|
||||
dialog: Dialog,
|
||||
logger: Logger,
|
||||
): Dialog {
|
||||
return new LoggingDialogDecorator(dialog, logger);
|
||||
}
|
||||
|
||||
class LoggingDialogDecorator implements Dialog {
|
||||
constructor(
|
||||
private readonly dialog: Dialog,
|
||||
private readonly logger: Logger,
|
||||
) { }
|
||||
|
||||
public async saveFile(
|
||||
fileContents: string,
|
||||
defaultFilename: string,
|
||||
fileType: FileType,
|
||||
) {
|
||||
this.logger.info(`Opening save file dialog with default filename: ${defaultFilename}.`);
|
||||
const dialogResult = await this.dialog.saveFile(fileContents, defaultFilename, fileType);
|
||||
if (dialogResult.success) {
|
||||
this.logger.info('File saving process completed successfully.');
|
||||
} else {
|
||||
this.logger.error('Error encountered while saving the file.', dialogResult.error);
|
||||
}
|
||||
return dialogResult;
|
||||
}
|
||||
|
||||
public showError(title: string, message: string) {
|
||||
this.logger.error(`Showing error dialog: ${title} - ${message}`);
|
||||
this.dialog.showError(title, message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user