authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:59:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:59:27-07:00
logbe4a38e57876d95f85a8cb12497a652a575cad0c
tree4cfb0f210cfe6dc0ee369e24493ae519e19cd139
parentf50a7cddc19336e3f4de820e198564f7e7f75455
signature Signed by PGP key B5690EEEBB952194

[codex] add structured Electron dialog errors (#3284)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 242 insertions(+), 14 deletions(-)

apps/desktop/src/electron/ElectronDialog.test.ts+113
......@@ -1,4 +1,5 @@
11import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
23import * as Effect from "effect/Effect";
34import * as Option from "effect/Option";
45import type { BrowserWindow } from "electron";
......@@ -90,4 +91,116 @@ describe("ElectronDialog", () => {
9091 ]);
9192 }).pipe(Effect.provide(ElectronDialog.layer)),
9293 );
94
95 it.effect("preserves folder picker request context and cause", () =>
96 Effect.gen(function* () {
97 const cause = new Error("folder picker failed");
98 const owner = { id: 7 } as BrowserWindow;
99 showOpenDialogMock.mockRejectedValue(cause);
100 const dialog = yield* ElectronDialog.ElectronDialog;
101
102 const error = yield* Effect.flip(
103 dialog.pickFolder({
104 owner: Option.some(owner),
105 defaultPath: Option.some("/workspace"),
106 }),
107 );
108
109 assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError);
110 assert.isTrue(ElectronDialog.isElectronDialogError(error));
111 assert.strictEqual(error.ownerWindowId, 7);
112 assert.strictEqual(error.defaultPath, "/workspace");
113 assert.strictEqual(error.cause, cause);
114 assert.include(error.message, "window 7");
115 assert.include(error.message, "/workspace");
116 assert.notInclude(error.message, cause.message);
117 }).pipe(Effect.provide(ElectronDialog.layer)),
118 );
119
120 it.effect("preserves confirmation request context and cause", () =>
121 Effect.gen(function* () {
122 const cause = new Error("confirmation failed");
123 const owner = { id: 9 } as BrowserWindow;
124 showMessageBoxMock.mockRejectedValue(cause);
125 const dialog = yield* ElectronDialog.ElectronDialog;
126
127 const error = yield* Effect.flip(
128 dialog.confirm({
129 owner: Option.some(owner),
130 message: " Confirm removal? ",
131 }),
132 );
133
134 assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError);
135 assert.strictEqual(error.ownerWindowId, 9);
136 assert.strictEqual(error.promptLength, "Confirm removal?".length);
137 assert.notProperty(error, "promptMessage");
138 assert.strictEqual(error.cause, cause);
139 assert.include(error.message, "window 9");
140 assert.notInclude(error.message, "Confirm removal?");
141 assert.notInclude(error.message, cause.message);
142 }).pipe(Effect.provide(ElectronDialog.layer)),
143 );
144
145 it.effect("preserves message box request context and cause", () =>
146 Effect.gen(function* () {
147 const cause = new Error("message box failed");
148 showMessageBoxMock.mockRejectedValue(cause);
149 const dialog = yield* ElectronDialog.ElectronDialog;
150
151 const error = yield* Effect.flip(
152 dialog.showMessageBox({
153 type: "warning",
154 title: "Unsaved changes",
155 message: "Discard changes?",
156 detail: "This cannot be undone.",
157 buttons: ["Cancel", "Discard"],
158 }),
159 );
160
161 assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError);
162 assert.strictEqual(error.type, "warning");
163 assert.strictEqual(error.titleLength, "Unsaved changes".length);
164 assert.strictEqual(error.messageLength, "Discard changes?".length);
165 assert.strictEqual(error.detailLength, "This cannot be undone.".length);
166 assert.strictEqual(error.buttonCount, 2);
167 assert.notProperty(error, "title");
168 assert.notProperty(error, "dialogMessage");
169 assert.notProperty(error, "dialogDetail");
170 assert.notProperty(error, "buttons");
171 assert.strictEqual(error.cause, cause);
172 assert.include(error.message, "warning");
173 assert.notInclude(error.message, "Unsaved changes");
174 assert.notInclude(error.message, "Discard changes?");
175 assert.notInclude(error.message, "This cannot be undone.");
176 assert.notInclude(error.message, "Cancel");
177 assert.notInclude(error.message, "Discard");
178 assert.notInclude(error.message, cause.message);
179 }).pipe(Effect.provide(ElectronDialog.layer)),
180 );
181
182 it.effect("preserves error box request context and cause in the defect", () =>
183 Effect.gen(function* () {
184 const cause = new Error("error box failed");
185 showErrorBoxMock.mockImplementation(() => {
186 throw cause;
187 });
188 const dialog = yield* ElectronDialog.ElectronDialog;
189
190 const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start."));
191
192 assert.isTrue(exit._tag === "Failure");
193 if (exit._tag === "Success") return;
194 const error = Cause.squash(exit.cause);
195 assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError);
196 assert.strictEqual(error.titleLength, "Startup failed".length);
197 assert.strictEqual(error.contentLength, "Could not start.".length);
198 assert.notProperty(error, "title");
199 assert.notProperty(error, "content");
200 assert.strictEqual(error.cause, cause);
201 assert.notInclude(error.message, "Startup failed");
202 assert.notInclude(error.message, "Could not start.");
203 assert.notInclude(error.message, cause.message);
204 }).pipe(Effect.provide(ElectronDialog.layer)),
205 );
93206});
apps/desktop/src/electron/ElectronDialog.ts+129-14
......@@ -2,11 +2,80 @@ import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
56
67import * as Electron from "electron";
78
89const CONFIRM_BUTTON_INDEX = 1;
910
11export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()(
12 "ElectronDialogPickFolderError",
13 {
14 ownerWindowId: Schema.NullOr(Schema.Number),
15 defaultPath: Schema.NullOr(Schema.String),
16 cause: Schema.Defect(),
17 },
18) {
19 override get message(): string {
20 const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
21 const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath;
22 return `Failed to open the Electron folder picker for ${owner} with ${defaultPath}.`;
23 }
24}
25
26export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()(
27 "ElectronDialogConfirmError",
28 {
29 ownerWindowId: Schema.NullOr(Schema.Number),
30 promptLength: Schema.Number,
31 cause: Schema.Defect(),
32 },
33) {
34 override get message(): string {
35 const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
36 return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`;
37 }
38}
39
40export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()(
41 "ElectronDialogShowMessageBoxError",
42 {
43 type: Schema.NullOr(Schema.Literals(["none", "info", "error", "question", "warning"])),
44 titleLength: Schema.NullOr(Schema.Number),
45 messageLength: Schema.Number,
46 detailLength: Schema.NullOr(Schema.Number),
47 buttonCount: Schema.Number,
48 cause: Schema.Defect(),
49 },
50) {
51 override get message(): string {
52 const type = this.type === null ? "untyped" : this.type;
53 return `Failed to show the Electron ${type} message box with ${this.buttonCount} buttons.`;
54 }
55}
56
57export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<ElectronDialogShowErrorBoxError>()(
58 "ElectronDialogShowErrorBoxError",
59 {
60 titleLength: Schema.Number,
61 contentLength: Schema.Number,
62 cause: Schema.Defect(),
63 },
64) {
65 override get message(): string {
66 return `Failed to show the Electron error box with a ${this.titleLength}-character title and ${this.contentLength}-character content.`;
67 }
68}
69
70export const ElectronDialogError = Schema.Union([
71 ElectronDialogPickFolderError,
72 ElectronDialogConfirmError,
73 ElectronDialogShowMessageBoxError,
74 ElectronDialogShowErrorBoxError,
75]);
76export type ElectronDialogError = typeof ElectronDialogError.Type;
77export const isElectronDialogError = Schema.is(ElectronDialogError);
78
1079export interface ElectronDialogPickFolderInput {
1180 readonly owner: Option.Option<Electron.BrowserWindow>;
1281 readonly defaultPath: Option.Option<string>;
......@@ -22,17 +91,24 @@ export class ElectronDialog extends Context.Service<
2291 {
2392 readonly pickFolder: (
2493 input: ElectronDialogPickFolderInput,
25 ) => Effect.Effect<Option.Option<string>>;
26 readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect<boolean>;
94 ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>;
95 readonly confirm: (
96 input: ElectronDialogConfirmInput,
97 ) => Effect.Effect<boolean, ElectronDialogConfirmError>;
2798 readonly showMessageBox: (
2899 options: Electron.MessageBoxOptions,
29 ) => Effect.Effect<Electron.MessageBoxReturnValue>;
100 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
30101 readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
31102 }
32103>()("@t3tools/desktop/electron/ElectronDialog") {}
33104
34105export const make = ElectronDialog.of({
35106 pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) {
107 const ownerWindowId = Option.match(input.owner, {
108 onNone: () => null,
109 onSome: (owner) => owner.id,
110 });
111 const defaultPath = Option.getOrNull(input.defaultPath);
36112 const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, {
37113 onNone: () => ({
38114 properties: ["openDirectory", "createDirectory"],
......@@ -42,10 +118,18 @@ export const make = ElectronDialog.of({
42118 defaultPath,
43119 }),
44120 });
45 const result = yield* Option.match(input.owner, {
46 onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)),
47 onSome: (owner) =>
48 Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)),
121 const result = yield* Effect.tryPromise({
122 try: () =>
123 Option.match(input.owner, {
124 onNone: () => Electron.dialog.showOpenDialog(openDialogOptions),
125 onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions),
126 }),
127 catch: (cause) =>
128 new ElectronDialogPickFolderError({
129 ownerWindowId,
130 defaultPath,
131 cause,
132 }),
49133 });
50134
51135 if (result.canceled) {
......@@ -67,17 +151,48 @@ export const make = ElectronDialog.of({
67151 noLink: true,
68152 message: normalizedMessage,
69153 };
70 const result = yield* Option.match(input.owner, {
71 onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)),
72 onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)),
154 const ownerWindowId = Option.match(input.owner, {
155 onNone: () => null,
156 onSome: (owner) => owner.id,
157 });
158 const result = yield* Effect.tryPromise({
159 try: () =>
160 Option.match(input.owner, {
161 onNone: () => Electron.dialog.showMessageBox(options),
162 onSome: (owner) => Electron.dialog.showMessageBox(owner, options),
163 }),
164 catch: (cause) =>
165 new ElectronDialogConfirmError({
166 ownerWindowId,
167 promptLength: normalizedMessage.length,
168 cause,
169 }),
73170 });
74171 return result.response === CONFIRM_BUTTON_INDEX;
75172 }),
76 showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)),
77 showErrorBox: (title, content) =>
78 Effect.sync(() => {
79 Electron.dialog.showErrorBox(title, content);
173 showMessageBox: (options) =>
174 Effect.tryPromise({
175 try: () => Electron.dialog.showMessageBox(options),
176 catch: (cause) =>
177 new ElectronDialogShowMessageBoxError({
178 type: options.type ?? null,
179 titleLength: options.title?.length ?? null,
180 messageLength: options.message.length,
181 detailLength: options.detail?.length ?? null,
182 buttonCount: options.buttons?.length ?? 0,
183 cause,
184 }),
80185 }),
186 showErrorBox: (title, content) =>
187 Effect.try({
188 try: () => Electron.dialog.showErrorBox(title, content),
189 catch: (cause) =>
190 new ElectronDialogShowErrorBoxError({
191 titleLength: title.length,
192 contentLength: content.length,
193 cause,
194 }),
195 }).pipe(Effect.orDie),
81196});
82197
83198export const layer = Layer.succeed(ElectronDialog, make);