Skip to content
Open
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
9 changes: 9 additions & 0 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export class ElectronMenu extends Context.Service<
readonly setApplicationMenu: (
template: readonly Electron.MenuItemConstructorOptions[],
) => Effect.Effect<void>;
readonly pasteAndMatchStyle: Effect.Effect<void>;
readonly showContextMenu: (
input: ElectronMenuContextInput,
) => Effect.Effect<Option.Option<string>>;
Expand Down Expand Up @@ -190,6 +191,14 @@ export const make = Effect.gen(function* () {
};

return ElectronMenu.of({
pasteAndMatchStyle: Effect.sync(() => {
const focused = Electron.webContents.getFocusedWebContents();
// A mounted guest can report focus while the host owns the focused frame.
const target = focused?.hostWebContents ?? focused;
if (target && !target.isDestroyed()) {
target.pasteAndMatchStyle();
}
}),
setApplicationMenu: (template) =>
Effect.try({
try: () => {
Expand Down
207 changes: 150 additions & 57 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";

const { pasteAndMatchStyleMock, getFocusedWebContentsMock } = vi.hoisted(() => ({
pasteAndMatchStyleMock: vi.fn(),
getFocusedWebContentsMock: vi.fn(),
}));

vi.mock("electron", () => ({
webContents: { getFocusedWebContents: getFocusedWebContentsMock },
}));

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
Expand Down Expand Up @@ -72,6 +83,8 @@ const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, {
installPrepared: () => Effect.die("unexpected installPrepared"),
} satisfies DesktopUpdates.DesktopUpdates["Service"]);

const dispatchMenuActionMock = vi.fn();

const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>
Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected createMain"),
Expand All @@ -84,7 +97,11 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
prepareCaptureReveal: Effect.void,
dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
dispatchMenuAction: (action, options) =>
Effect.sync(() => dispatchMenuActionMock(action, options)).pipe(
Effect.andThen(Deferred.succeed(selectedAction, action)),
Effect.asVoid,
),
dispatchSnapShotEvent: () => Effect.void,
zoomMain: (direction) =>
Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
Expand All @@ -95,13 +112,18 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>

const layerElectronMenu = (
applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
platform: NodeJS.Platform,
) =>
Layer.succeed(ElectronMenu.ElectronMenu, {
setApplicationMenu: (template) =>
Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid),
popupTemplate: () => Effect.void,
showContextMenu: () => Effect.succeedNone,
} satisfies ElectronMenu.ElectronMenu["Service"]);
Layer.effect(
ElectronMenu.ElectronMenu,
ElectronMenu.make.pipe(
Effect.map((menu) => ({
...menu,
setApplicationMenu: (template: readonly Electron.MenuItemConstructorOptions[]) =>
Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid),
})),
),
).pipe(Layer.provide(Layer.succeed(HostProcess.Platform, platform)));

const configureMenu = (
selectedAction: Deferred.Deferred<string>,
Expand All @@ -114,7 +136,12 @@ const configureMenu = (
}).pipe(
Effect.provide(
DesktopApplicationMenu.layer.pipe(
Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)),
Layer.provideMerge(
layerElectronMenu(
applicationMenuTemplate,
environment.platform ?? environmentInput.platform,
),
),
Layer.provideMerge(layerDesktopWindow(selectedAction)),
Layer.provideMerge(layerDesktopUpdates),
Layer.provideMerge(layerElectronDialog),
Expand All @@ -129,6 +156,15 @@ const configureMenu = (
);

describe("DesktopApplicationMenu", () => {
beforeEach(() => {
dispatchMenuActionMock.mockClear();
pasteAndMatchStyleMock.mockClear();
getFocusedWebContentsMock.mockReset().mockReturnValue({
isDestroyed: () => false,
pasteAndMatchStyle: pasteAndMatchStyleMock,
});
});

it.effect("keeps display branding in the macOS application menu", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
Expand Down Expand Up @@ -188,63 +224,120 @@ describe("DesktopApplicationMenu", () => {
}),
);

it.effect("owns Paste as Text and routes it through the renderer", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
it.effect.each(["darwin", "linux", "win32"] as const)(
"routes a Paste as Text menu click through the renderer once on %s",
(platform) =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);
yield* configureMenu(selectedAction, applicationMenuTemplate, { platform });

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
assert.isDefined(editMenu);
if (!Array.isArray(editMenu.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
assert.isDefined(pasteAsTextItem);
assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V");
if (typeof pasteAsTextItem.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}
const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
assert.isDefined(editMenu);
if (!Array.isArray(editMenu.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
assert.isDefined(pasteAsTextItem);
assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V");
assert.isUndefined(pasteAsTextItem.role);
if (typeof pasteAsTextItem.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}

pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent,
);
assert.equal(yield* Deferred.await(selectedAction), "paste-as-text");
}),
pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent,
);
assert.equal(yield* Deferred.await(selectedAction), "paste-as-text");
assert.deepEqual(dispatchMenuActionMock.mock.calls, [["paste-as-text", { reveal: false }]]);
assert.equal(pasteAndMatchStyleMock.mock.calls.length, 0);
}),
);

it.effect("leaves the accelerator to Chromium instead of injecting a paste", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
it.effect.each(["linux", "win32"] as const)(
"leaves the %s accelerator to Chromium instead of injecting a paste",
(platform) =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);
yield* configureMenu(selectedAction, applicationMenuTemplate, { platform });

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
if (!Array.isArray(editMenu?.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
if (typeof pasteAsTextItem?.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}
const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
if (!Array.isArray(editMenu?.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
if (typeof pasteAsTextItem?.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}

pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{
triggeredByAccelerator: true,
} as unknown as KeyboardEvent,
);
assert.isFalse(yield* Deferred.isDone(selectedAction));
}),
pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{
triggeredByAccelerator: true,
} as unknown as KeyboardEvent,
);
assert.isFalse(yield* Deferred.isDone(selectedAction));
assert.equal(dispatchMenuActionMock.mock.calls.length, 0);
assert.equal(pasteAndMatchStyleMock.mock.calls.length, 0);
}),
);

it.effect(
"pastes in the focused contents once before the macOS accelerator callback returns",
() =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate, { platform: "darwin" });

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
if (!Array.isArray(editMenu?.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
assert.equal(pasteAsTextItem?.accelerator, "CmdOrCtrl+Shift+V");
if (typeof pasteAsTextItem?.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}
assert.isUndefined(pasteAsTextItem.role);

const windowPaste = vi.fn();
const guestPaste = vi.fn();
const host = { isDestroyed: () => false, pasteAndMatchStyle: pasteAndMatchStyleMock };
for (const focused of [
host,
{ ...host, hostWebContents: host, pasteAndMatchStyle: guestPaste },
]) {
pasteAndMatchStyleMock.mockClear();
getFocusedWebContentsMock.mockReturnValue(focused);
pasteAsTextItem.click(
{} as Electron.MenuItem,
{
webContents: { pasteAndMatchStyle: windowPaste },
} as unknown as Electron.BrowserWindow,
{ triggeredByAccelerator: true } as unknown as KeyboardEvent,
);
// No yield before this assertion: the native dispatch must not wait for a renderer round trip.
assert.equal(pasteAndMatchStyleMock.mock.calls.length, 1);
assert.equal(guestPaste.mock.calls.length, 0);
assert.equal(windowPaste.mock.calls.length, 0);
assert.equal(dispatchMenuActionMock.mock.calls.length, 0);
}
assert.isFalse(yield* Deferred.isDone(selectedAction));
}),
);

it.effect("routes View menu zoom to the main window instead of zoom roles", () =>
Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const make = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
const runPromise = Effect.runPromiseWith(context);
const runSync = Effect.runSyncWith(context);

const runMenuEffect = <E>(
action: string,
Expand All @@ -143,16 +144,19 @@ export const make = Effect.gen(function* () {
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
// Chromium already pastes as plain text for this chord, so the accelerator
// needs nothing from the menu: the composer and the terminal each arm
// themselves from the same keydown. Routing it through the renderer anyway
// lands a second, injected paste and doubles the text.
const pasteAsTextClick = (
_item: Electron.MenuItem,
_window: Electron.BaseWindow | undefined,
event: Electron.KeyboardEvent,
) => {
if (event.triggeredByAccelerator === true) return;
if (event.triggeredByAccelerator === true) {
// Windows/Linux already paste through Chromium. On macOS, a renderer
// round trip can arrive after the terminal's clipboard read and paste twice.
if (environment.platform === "darwin") {
runSync(electronMenu.pasteAndMatchStyle);
}
return;
}
runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ const layerDesktopServerExposure = Layer.succeed(DesktopServerExposure.DesktopSe
} satisfies DesktopServerExposure.DesktopServerExposure["Service"]);

const layerElectronMenu = Layer.succeed(ElectronMenu.ElectronMenu, {
pasteAndMatchStyle: Effect.void,
setApplicationMenu: () => Effect.void,
popupTemplate: () => Effect.void,
showContextMenu: () => Effect.succeedNone,
Expand Down Expand Up @@ -308,6 +309,7 @@ function layerTest(input: {
DesktopState.layer,
layerElectronApp,
Layer.succeed(ElectronMenu.ElectronMenu, {
pasteAndMatchStyle: Effect.void,
setApplicationMenu: () => Effect.void,
showContextMenu: () => Effect.succeedNone,
popupTemplate: input.onPopupTemplate ?? (() => Effect.void),
Expand Down
Loading