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
98 changes: 94 additions & 4 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { assert, describe, it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";
Expand All @@ -24,6 +27,10 @@ vi.mock("electron", () => ({

import * as ElectronMenu from "./ElectronMenu.ts";

const TestLayer = ElectronMenu.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
);

describe("ElectronMenu", () => {
beforeEach(() => {
buildFromTemplateMock.mockReset();
Expand All @@ -42,7 +49,7 @@ describe("ElectronMenu", () => {

assert.isTrue(Option.isNone(selectedItemId));
assert.equal(buildFromTemplateMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronMenu.layer)),
}).pipe(Effect.provide(TestLayer)),
);

it.effect("resolves with the clicked leaf item id", () =>
Expand All @@ -69,7 +76,7 @@ describe("ElectronMenu", () => {
});

assert.equal(Option.getOrNull(selectedItemId), "copy");
}).pipe(Effect.provide(ElectronMenu.layer)),
}).pipe(Effect.provide(TestLayer)),
);

it.effect("resolves with none when the menu closes without a click", () =>
Expand All @@ -93,7 +100,7 @@ describe("ElectronMenu", () => {
enabled: true,
click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
});
}).pipe(Effect.provide(ElectronMenu.layer)),
}).pipe(Effect.provide(TestLayer)),
);

it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
Expand All @@ -114,6 +121,89 @@ describe("ElectronMenu", () => {

assert.equal(buildFromTemplateMock.mock.calls.length, 1);
assert.equal(popupMock.mock.calls.length, 1);
}).pipe(Effect.provide(ElectronMenu.layer)),
}).pipe(Effect.provide(TestLayer)),
);

it.effect("preserves application-menu failures as structured defects", () =>
Effect.gen(function* () {
const cause = new Error("application menu build failed");
buildFromTemplateMock.mockImplementationOnce(() => {
throw cause;
});

const electronMenu = yield* ElectronMenu.ElectronMenu;
const exit = yield* Effect.exit(
electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]),
);

assert.equal(exit._tag, "Failure");
if (exit._tag === "Failure") {
const error = Cause.squash(exit.cause);
assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
assert.equal(error.operation, "set-application-menu");
assert.equal(error.platform, "linux");
assert.isNull(error.windowId);
assert.equal(error.itemCount, 2);
assert.strictEqual(error.cause, cause);
assert.notInclude(error.message, cause.message);
}
}).pipe(Effect.provide(TestLayer)),
);

it.effect("preserves popup-template failures with window context", () =>
Effect.gen(function* () {
const cause = new Error("popup failed");
buildFromTemplateMock.mockReturnValueOnce({
popup: () => {
throw cause;
},
});

const electronMenu = yield* ElectronMenu.ElectronMenu;
const exit = yield* Effect.exit(
electronMenu.popupTemplate({
window: { id: 41 } as Electron.BrowserWindow,
template: [{ label: "Copy" }],
}),
);

assert.equal(exit._tag, "Failure");
if (exit._tag === "Failure") {
const error = Cause.squash(exit.cause);
assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
assert.equal(error.operation, "popup-template");
assert.equal(error.windowId, 41);
assert.equal(error.itemCount, 1);
assert.strictEqual(error.cause, cause);
}
}).pipe(Effect.provide(TestLayer)),
);

it.effect("preserves context-menu failures with normalized item context", () =>
Effect.gen(function* () {
const cause = new Error("context menu build failed");
buildFromTemplateMock.mockImplementationOnce(() => {
throw cause;
});

const electronMenu = yield* ElectronMenu.ElectronMenu;
const exit = yield* Effect.exit(
electronMenu.showContextMenu({
window: { id: 42 } as Electron.BrowserWindow,
items: [{ id: "copy", label: "Copy" }],
position: Option.none(),
}),
);

assert.equal(exit._tag, "Failure");
if (exit._tag === "Failure") {
const error = Cause.squash(exit.cause);
assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
assert.equal(error.operation, "show-context-menu");
assert.equal(error.windowId, 42);
assert.equal(error.itemCount, 1);
assert.strictEqual(error.cause, cause);
}
}).pipe(Effect.provide(TestLayer)),
);
});
109 changes: 85 additions & 24 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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";

Expand All @@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput {
readonly template: readonly Electron.MenuItemConstructorOptions[];
}

const ElectronMenuOperation = Schema.Literals([
"set-application-menu",
"popup-template",
"show-context-menu",
]);

export class ElectronMenuOperationError extends Schema.TaggedErrorClass<ElectronMenuOperationError>()(
"ElectronMenuOperationError",
{
operation: ElectronMenuOperation,
platform: Schema.String,
windowId: Schema.NullOr(Schema.Number),
itemCount: Schema.Number,
cause: Schema.Defect(),
},
) {
override get message(): string {
const window = this.windowId === null ? "" : ` for window ${this.windowId}`;
return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`;
}
}

export class ElectronMenu extends Context.Service<
ElectronMenu,
{
Expand Down Expand Up @@ -142,16 +165,36 @@ export const make = Effect.gen(function* () {

return ElectronMenu.of({
setApplicationMenu: (template) =>
Effect.sync(() => {
Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
}),
Effect.try({
try: () => {
Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
},
catch: (cause) =>
new ElectronMenuOperationError({
operation: "set-application-menu",
platform,
windowId: null,
itemCount: template.length,
cause,
}),
}).pipe(Effect.orDie),
popupTemplate: (input) =>
Effect.sync(() => {
if (input.template.length === 0) {
return;
}
Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
}),
input.template.length === 0
? Effect.void
: Effect.try({
try: () =>
Electron.Menu.buildFromTemplate([...input.template]).popup({
window: input.window,
}),
catch: (cause) =>
new ElectronMenuOperationError({
operation: "popup-template",
platform,
windowId: input.window.id,
itemCount: input.template.length,
cause,
}),
}).pipe(Effect.orDie),
showContextMenu: (input) =>
Effect.callback<Option.Option<string>>((resume) => {
const normalizedItems = normalizeContextMenuItems(input.items);
Expand All @@ -169,21 +212,39 @@ export const make = Effect.gen(function* () {
resume(Effect.succeed(selectedItemId));
};

const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
callback: () => complete(Option.none()),
}),
onSome: (position): Electron.PopupOptions => ({
window: input.window,
x: position.x,
y: position.y,
callback: () => complete(Option.none()),
}),
});
menu.popup(popupOptions);
try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
callback: () => complete(Option.none()),
}),
onSome: (position): Electron.PopupOptions => ({
window: input.window,
x: position.x,
y: position.y,
callback: () => complete(Option.none()),
}),
});
menu.popup(popupOptions);
} catch (cause) {
if (completed) {
return;
}
completed = true;
resume(
Effect.die(
new ElectronMenuOperationError({
operation: "show-context-menu",
platform,
windowId: input.window.id,
itemCount: normalizedItems.length,
cause,
}),
),
);
}
}),
});
});
Expand Down
Loading