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,8 +1,35 @@
|
||||
export interface Dialog {
|
||||
saveFile(fileContents: string, fileName: string, type: FileType): Promise<void>;
|
||||
showError(title: string, message: string): void;
|
||||
saveFile(fileContents: string, defaultFilename: string, type: FileType): Promise<SaveFileOutcome>;
|
||||
}
|
||||
|
||||
export enum FileType {
|
||||
BatchFile,
|
||||
ShellScript,
|
||||
}
|
||||
|
||||
export type SaveFileOutcome = SuccessfulSaveFile | FailedSaveFile;
|
||||
|
||||
interface SaveFileStatus {
|
||||
readonly success: boolean;
|
||||
readonly error?: SaveFileError;
|
||||
}
|
||||
|
||||
interface SuccessfulSaveFile extends SaveFileStatus {
|
||||
readonly success: true;
|
||||
readonly error?: SaveFileError;
|
||||
}
|
||||
|
||||
interface FailedSaveFile extends SaveFileStatus {
|
||||
readonly success: false;
|
||||
readonly error: SaveFileError;
|
||||
}
|
||||
|
||||
export interface SaveFileError {
|
||||
readonly type: SaveFileErrorType;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type SaveFileErrorType =
|
||||
| 'FileCreationError'
|
||||
| 'DialogDisplayError';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="canRun"
|
||||
text="Run"
|
||||
icon-name="play"
|
||||
@click="executeCode"
|
||||
@click="runCode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
import IconButton from './IconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -21,6 +22,7 @@ export default defineComponent({
|
||||
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
|
||||
const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
|
||||
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
|
||||
const { dialog } = injectKey((keys) => keys.useDialog);
|
||||
|
||||
const canRun = computed<boolean>(() => getCanRunState(
|
||||
currentState.value.os,
|
||||
@@ -28,17 +30,20 @@ export default defineComponent({
|
||||
os,
|
||||
));
|
||||
|
||||
async function executeCode() {
|
||||
async function runCode() {
|
||||
if (!codeRunner) { throw new Error('missing code runner'); }
|
||||
await codeRunner.runCode(
|
||||
const { success, error } = await codeRunner.runCode(
|
||||
currentContext.state.code.current,
|
||||
currentContext.state.collection.scripting.fileExtension,
|
||||
);
|
||||
if (!success) {
|
||||
showScriptRunError(dialog, `${error.type}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canRun,
|
||||
executeCode,
|
||||
runCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -51,4 +56,24 @@ function getCanRunState(
|
||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||
return isRunningAsDesktopApplication && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
function showScriptRunError(dialog: Dialog, technicalDetails: string) {
|
||||
dialog.showError(
|
||||
'Error Running Script',
|
||||
[
|
||||
'We encountered an issue while running the script.',
|
||||
'This could be due to a variety of factors such as system permissions, resource constraints, or security software interventions.',
|
||||
'\n',
|
||||
'Here are some steps you can take:',
|
||||
'- Confirm that you have the necessary permissions to execute scripts on your system.',
|
||||
'- Check if there is sufficient disk space and system resources available.',
|
||||
'- Antivirus or security software can sometimes mistakenly block script execution. If you suspect this, verify your security settings, or temporarily disable the security software to see if that resolves the issue.',
|
||||
'- If possible, try running a different script to determine if the issue is specific to a particular script.',
|
||||
'- Should the problem persist, reach out to the community for further assistance.',
|
||||
'\n',
|
||||
'For your reference, here are the technical details of the error:',
|
||||
technicalDetails,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,8 +19,8 @@ import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
|
||||
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
||||
import IconButton from '../IconButton.vue';
|
||||
import InstructionList from './Instructions/InstructionList.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
@@ -38,25 +38,28 @@ export default defineComponent({
|
||||
const { dialog } = injectKey((keys) => keys.useDialog);
|
||||
|
||||
const areInstructionsVisible = ref(false);
|
||||
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
||||
const filename = computed<string>(() => buildFilename(currentState.value.collection.scripting));
|
||||
const instructions = computed<IInstructionListData | undefined>(() => getInstructions(
|
||||
currentState.value.collection.os,
|
||||
fileName.value,
|
||||
filename.value,
|
||||
));
|
||||
|
||||
async function saveCode() {
|
||||
await dialog.saveFile(
|
||||
const { success, error } = await dialog.saveFile(
|
||||
currentState.value.code.current,
|
||||
fileName.value,
|
||||
filename.value,
|
||||
getType(currentState.value.collection.scripting.language),
|
||||
);
|
||||
if (!success) {
|
||||
showScriptSaveError(dialog, `${error.type}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
areInstructionsVisible.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
isRunningAsDesktopApplication,
|
||||
instructions,
|
||||
fileName,
|
||||
areInstructionsVisible,
|
||||
saveCode,
|
||||
};
|
||||
@@ -74,10 +77,30 @@ function getType(language: ScriptingLanguage) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildFileName(scripting: IScriptingDefinition) {
|
||||
function buildFilename(scripting: IScriptingDefinition) {
|
||||
if (scripting.fileExtension) {
|
||||
return `${ScriptFileName}.${scripting.fileExtension}`;
|
||||
return `${ScriptFilename}.${scripting.fileExtension}`;
|
||||
}
|
||||
return ScriptFileName;
|
||||
return ScriptFilename;
|
||||
}
|
||||
|
||||
function showScriptSaveError(dialog: Dialog, technicalDetails: string) {
|
||||
dialog.showError(
|
||||
'Error Saving Script',
|
||||
[
|
||||
'An error occurred while saving the script.',
|
||||
'This issue may arise from insufficient permissions, limited disk space, or interference from security software.',
|
||||
'\n',
|
||||
'To address this:',
|
||||
'- Verify your permissions for the selected save directory.',
|
||||
'- Check available disk space.',
|
||||
'- Review your antivirus or security settings; adding an exclusion for privacy.sexy might be necessary.',
|
||||
'- Try saving the script to a different location or modifying your selection.',
|
||||
'- If the problem persists, reach out to the community for further assistance.',
|
||||
'\n',
|
||||
'Technical Details:',
|
||||
technicalDetails,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
||||
import { decorateWithLogging } from '@/infrastructure/Dialog/LoggingDialogDecorator';
|
||||
import { ClientLoggerFactory } from '../Log/ClientLoggerFactory';
|
||||
|
||||
export function determineDialogBasedOnEnvironment(
|
||||
export function createEnvironmentSpecificLoggedDialog(
|
||||
environment: RuntimeEnvironment,
|
||||
dialogLoggingDecorator: DialogLoggingDecorator = ClientLoggingDecorator,
|
||||
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
|
||||
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
|
||||
): Dialog {
|
||||
const dialog = determineDialogBasedOnEnvironment(
|
||||
environment,
|
||||
windowInjectedDialogFactory,
|
||||
browserDialogFactory,
|
||||
);
|
||||
const loggingDialog = dialogLoggingDecorator(dialog);
|
||||
return loggingDialog;
|
||||
}
|
||||
|
||||
function determineDialogBasedOnEnvironment(
|
||||
environment: RuntimeEnvironment,
|
||||
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
|
||||
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
|
||||
@@ -10,16 +27,23 @@ export function determineDialogBasedOnEnvironment(
|
||||
if (!environment.isRunningAsDesktopApplication) {
|
||||
return browserDialogFactory();
|
||||
}
|
||||
const dialog = windowInjectedDialogFactory();
|
||||
if (!dialog) {
|
||||
const windowDialog = windowInjectedDialogFactory();
|
||||
if (!windowDialog) {
|
||||
throw new Error([
|
||||
'The Dialog API could not be retrieved from the window object.',
|
||||
'Failed to retrieve Dialog API from window object in desktop environment.',
|
||||
'This may indicate that the Dialog API is either not implemented or not correctly exposed in the current desktop environment.',
|
||||
].join('\n'));
|
||||
}
|
||||
return dialog;
|
||||
return windowDialog;
|
||||
}
|
||||
|
||||
export type WindowDialogCreationFunction = () => Dialog | undefined;
|
||||
|
||||
export type BrowserDialogCreationFunction = () => Dialog;
|
||||
|
||||
export type DialogLoggingDecorator = (dialog: Dialog) => Dialog;
|
||||
|
||||
const ClientLoggingDecorator: DialogLoggingDecorator = (dialog) => decorateWithLogging(
|
||||
dialog,
|
||||
ClientLoggerFactory.Current.logger,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import { determineDialogBasedOnEnvironment } from './ClientDialogFactory';
|
||||
import { createEnvironmentSpecificLoggedDialog } from './ClientDialogFactory';
|
||||
|
||||
export function useDialog(
|
||||
factory: DialogFactory = () => determineDialogBasedOnEnvironment(CurrentEnvironment),
|
||||
factory: DialogFactory = () => createEnvironmentSpecificLoggedDialog(CurrentEnvironment),
|
||||
) {
|
||||
const dialog = factory();
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IpcChannel } from './IpcChannel';
|
||||
|
||||
export const IpcChannelDefinitions = {
|
||||
CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']),
|
||||
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['saveFile']),
|
||||
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['showError', 'saveFile']),
|
||||
} as const;
|
||||
|
||||
export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions;
|
||||
|
||||
Reference in New Issue
Block a user