Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions apps/desktop/src/electron/ElectronDialog.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)),
);
});
143 changes: 129 additions & 14 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()(
"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>()(
"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>()(
"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>()(
"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<Electron.BrowserWindow>;
readonly defaultPath: Option.Option<string>;
Expand All @@ -22,17 +91,24 @@ export class ElectronDialog extends Context.Service<
{
readonly pickFolder: (
input: ElectronDialogPickFolderInput,
) => Effect.Effect<Option.Option<string>>;
readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect<boolean>;
) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>;
readonly confirm: (
input: ElectronDialogConfirmInput,
) => Effect.Effect<boolean, ElectronDialogConfirmError>;
readonly showMessageBox: (
options: Electron.MessageBoxOptions,
) => Effect.Effect<Electron.MessageBoxReturnValue>;
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
}
>()("@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"],
Expand All @@ -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) {
Expand All @@ -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,
Comment thread
juliusmarminge marked this conversation as resolved.
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);
Loading