diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 9be62e740b2..388b3fd2c15 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; @@ -90,4 +91,116 @@ describe("ElectronDialog", () => { ]); }).pipe(Effect.provide(ElectronDialog.layer)), ); + + it.effect("preserves folder picker request context and cause", () => + Effect.gen(function* () { + const cause = new Error("folder picker failed"); + const owner = { id: 7 } as BrowserWindow; + showOpenDialogMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.pickFolder({ + owner: Option.some(owner), + defaultPath: Option.some("/workspace"), + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); + assert.isTrue(ElectronDialog.isElectronDialogError(error)); + assert.strictEqual(error.ownerWindowId, 7); + assert.strictEqual(error.defaultPath, "/workspace"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 7"); + assert.include(error.message, "/workspace"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves confirmation request context and cause", () => + Effect.gen(function* () { + const cause = new Error("confirmation failed"); + const owner = { id: 9 } as BrowserWindow; + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.confirm({ + owner: Option.some(owner), + message: " Confirm removal? ", + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); + assert.strictEqual(error.ownerWindowId, 9); + assert.strictEqual(error.promptLength, "Confirm removal?".length); + assert.notProperty(error, "promptMessage"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 9"); + assert.notInclude(error.message, "Confirm removal?"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves message box request context and cause", () => + Effect.gen(function* () { + const cause = new Error("message box failed"); + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.showMessageBox({ + type: "warning", + title: "Unsaved changes", + message: "Discard changes?", + detail: "This cannot be undone.", + buttons: ["Cancel", "Discard"], + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); + assert.strictEqual(error.type, "warning"); + assert.strictEqual(error.titleLength, "Unsaved changes".length); + assert.strictEqual(error.messageLength, "Discard changes?".length); + assert.strictEqual(error.detailLength, "This cannot be undone.".length); + assert.strictEqual(error.buttonCount, 2); + assert.notProperty(error, "title"); + assert.notProperty(error, "dialogMessage"); + assert.notProperty(error, "dialogDetail"); + assert.notProperty(error, "buttons"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "warning"); + assert.notInclude(error.message, "Unsaved changes"); + assert.notInclude(error.message, "Discard changes?"); + assert.notInclude(error.message, "This cannot be undone."); + assert.notInclude(error.message, "Cancel"); + assert.notInclude(error.message, "Discard"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves error box request context and cause in the defect", () => + Effect.gen(function* () { + const cause = new Error("error box failed"); + showErrorBoxMock.mockImplementation(() => { + throw cause; + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start.")); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); + assert.strictEqual(error.titleLength, "Startup failed".length); + assert.strictEqual(error.contentLength, "Could not start.".length); + assert.notProperty(error, "title"); + assert.notProperty(error, "content"); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, "Startup failed"); + assert.notInclude(error.message, "Could not start."); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 057817ec7e6..be633971bea 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -2,11 +2,80 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; const CONFIRM_BUTTON_INDEX = 1; +export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFolderError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron folder picker for ${owner} with ${defaultPath}.`; + } +} + +export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( + "ElectronDialogConfirmError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + promptLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`; + } +} + +export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowMessageBoxError", + { + type: Schema.NullOr(Schema.Literals(["none", "info", "error", "question", "warning"])), + titleLength: Schema.NullOr(Schema.Number), + messageLength: Schema.Number, + detailLength: Schema.NullOr(Schema.Number), + buttonCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const type = this.type === null ? "untyped" : this.type; + return `Failed to show the Electron ${type} message box with ${this.buttonCount} buttons.`; + } +} + +export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowErrorBoxError", + { + titleLength: Schema.Number, + contentLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to show the Electron error box with a ${this.titleLength}-character title and ${this.contentLength}-character content.`; + } +} + +export const ElectronDialogError = Schema.Union([ + ElectronDialogPickFolderError, + ElectronDialogConfirmError, + ElectronDialogShowMessageBoxError, + ElectronDialogShowErrorBoxError, +]); +export type ElectronDialogError = typeof ElectronDialogError.Type; +export const isElectronDialogError = Schema.is(ElectronDialogError); + export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; @@ -22,17 +91,24 @@ export class ElectronDialog extends Context.Service< { readonly pickFolder: ( input: ElectronDialogPickFolderInput, - ) => Effect.Effect>; - readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly confirm: ( + input: ElectronDialogConfirmInput, + ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, - ) => Effect.Effect; + ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronDialog") {} export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ properties: ["openDirectory", "createDirectory"], @@ -42,10 +118,18 @@ export const make = ElectronDialog.of({ defaultPath, }), }); - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => - Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), }); if (result.canceled) { @@ -67,17 +151,48 @@ export const make = ElectronDialog.of({ noLink: true, message: normalizedMessage, }; - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), - onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (owner) => Electron.dialog.showMessageBox(owner, options), + }), + catch: (cause) => + new ElectronDialogConfirmError({ + ownerWindowId, + promptLength: normalizedMessage.length, + cause, + }), }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), - showErrorBox: (title, content) => - Effect.sync(() => { - Electron.dialog.showErrorBox(title, content); + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, + cause, + }), }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), + }).pipe(Effect.orDie), }); export const layer = Layer.succeed(ElectronDialog, make);