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
144 changes: 144 additions & 0 deletions electron/edit-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Regression cover for the macOS half of #433.
//
// The Edit menu used to carry `role: "undo"` / `role: "redo"` with
// `registerAccelerator: false`. That field is documented `@platform linux,win32`,
// so on darwin it does nothing at all: AppKit still matches the menu's Cmd+Z key
// equivalent inside `-[NSApplication sendEvent:]`, before the key event reaches
// the web contents, and the editor's own keydown handler never runs.
//
// These tests pin the shape that actually reaches the renderer on every platform:
// an explicit accelerator and a click that dispatches to the editor -- and, in the
// second describe, where that dispatch actually lands. Dropping the roles took
// `webContents.undo()` away from every window, so the non-editor fallback is not a
// detail: it is the half of the design that keeps the launch and notes windows
// working.

import { describe, expect, it, vi } from "vitest";
import {
buildEditMenuSubmenu,
type EditorUndoRedoChannel,
routeEditorUndoRedo,
type UndoRedoWindow,
} from "./edit-menu";

function build() {
const dispatch = vi.fn<(channel: EditorUndoRedoChannel) => void>();
const items = buildEditMenuSubmenu({
label: (_key, fallback) => fallback,
dispatch,
});
return { items, dispatch };
}

describe("buildEditMenuSubmenu", () => {
it("owns Cmd+Z itself instead of leaning on registerAccelerator", () => {
const { items } = build();
const undoItem = items.find((i) => i.label === "Undo");

expect(undoItem?.accelerator).toBe("CmdOrCtrl+Z");
// The two things that made the previous version a no-op on macOS.
expect(undoItem?.role).toBeUndefined();
expect(undoItem?.registerAccelerator).toBeUndefined();
});

it("owns Shift+Cmd+Z for redo on the same terms", () => {
const { items } = build();
const redoItem = items.find((i) => i.label === "Redo");

expect(redoItem?.accelerator).toBe("Shift+CmdOrCtrl+Z");
expect(redoItem?.role).toBeUndefined();
expect(redoItem?.registerAccelerator).toBeUndefined();
});

it("routes both to the editor renderer, which owns the document's undo stack", () => {
// `webContents.undo()` -- what the roles ran -- is the WEB EDITING undo. It does
// nothing outside a focused text field, so on macOS Cmd+Z was swallowed by a menu
// item that could not have serviced it anyway.
const { items, dispatch } = build();

items
.find((i) => i.label === "Undo")
?.click?.(
// The click signature carries a menu item, a window and the event; none of
// them are read here.
undefined as never,
undefined as never,
undefined as never,
);
expect(dispatch).toHaveBeenCalledWith("menu-undo");

items
.find((i) => i.label === "Redo")
?.click?.(undefined as never, undefined as never, undefined as never);
expect(dispatch).toHaveBeenCalledWith("menu-redo");
});

it("leaves the clipboard items as roles", () => {
// They act on the focused text selection, which is exactly what the roles do --
// and nothing in the editor shadows them.
const { items } = build();
expect(items.map((i) => i.role).filter(Boolean)).toEqual(["cut", "copy", "paste", "selectAll"]);
});
});

function editorWindow() {
const webContents = {
send: vi.fn<(channel: EditorUndoRedoChannel) => void>(),
undo: vi.fn(),
redo: vi.fn(),
};
const isDestroyed = vi.fn(() => false);
return {
window: { isDestroyed, webContents } satisfies UndoRedoWindow,
webContents,
isDestroyed,
};
}

describe("routeEditorUndoRedo", () => {
it("hands the editor window the request over IPC", () => {
// The editor renderer owns the document's undo stack, and applies the text-field
// rule its own keydown path applies. `webContents.undo()` could not do either.
const target = editorWindow();

routeEditorUndoRedo("menu-undo", target.window, () => true);
routeEditorUndoRedo("menu-redo", target.window, () => true);

expect(target.webContents.send.mock.calls).toEqual([["menu-undo"], ["menu-redo"]]);
expect(target.webContents.undo).not.toHaveBeenCalled();
expect(target.webContents.redo).not.toHaveBeenCalled();
});

it("falls back to the web-editing undo when the focused window is not the editor", () => {
// The case the whole design rests on. Dropping `role: "undo"` took the menu's
// built-in `webContents.undo()` away from EVERY window, so the launch window and
// the notes window -- where the role was the right answer -- get it back here.
const target = editorWindow();

routeEditorUndoRedo("menu-undo", target.window, () => false);
expect(target.webContents.undo).toHaveBeenCalledOnce();
expect(target.webContents.redo).not.toHaveBeenCalled();

routeEditorUndoRedo("menu-redo", target.window, () => false);
expect(target.webContents.redo).toHaveBeenCalledOnce();

expect(target.webContents.send).not.toHaveBeenCalled();
});

it("does nothing when there is no window, or it has been destroyed", () => {
// Unlike `sendEditorMenuAction` this never creates one: Cmd+Z is not a request to
// open the editor. And the destroyed check runs before anything reads the window,
// which is why `isEditor` is a callback -- `webContents.getURL()` on a destroyed
// window throws.
const isEditor = vi.fn(() => true);
expect(() => routeEditorUndoRedo("menu-undo", null, isEditor)).not.toThrow();

const target = editorWindow();
target.isDestroyed.mockReturnValue(true);
routeEditorUndoRedo("menu-undo", target.window, isEditor);

expect(target.webContents.send).not.toHaveBeenCalled();
expect(target.webContents.undo).not.toHaveBeenCalled();
expect(isEditor).not.toHaveBeenCalled();
});
});
107 changes: 107 additions & 0 deletions electron/edit-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// The application menu's Edit submenu, split out of `main.ts` so it can be tested
// (same shape as `about.ts`).
//
// Undo/Redo are deliberately NOT `role: "undo"` / `role: "redo"`.
//
// Those roles run `webContents.undo()`, the WEB EDITING undo, which does nothing
// at all outside a focused text field. On macOS their Cmd+Z key equivalent is
// matched by AppKit inside `-[NSApplication sendEvent:]`, BEFORE the key event
// reaches the web contents, so the editor's own document-level handler
// (`useUndoRedoShortcuts`) never sees the keydown and Ctrl+Z silently did
// nothing (#433).
//
// `registerAccelerator: false` does not fix that. Electron annotates the field
// `@platform linux,win32` (see `MenuItemConstructorOptions` in electron.d.ts),
// so on darwin it is ignored outright and the menu keeps the key equivalent.
// And on Windows and Linux the roles were never the problem: menu accelerators
// there are dispatched from the unhandled-keyboard-event path, i.e. AFTER the
// renderer, which the renderer's own `preventDefault()` already suppresses.
//
// So the items own the accelerator on every platform and forward to the editor
// renderer, which applies exactly the rule its keydown path applies: a focused
// text field gets the browser's text undo, anything else gets the document undo.
// `dispatch` is what falls back to `webContents.undo()` when the focused window
// is not the editor at all.

import type { MenuItemConstructorOptions } from "electron";

/** IPC channels the Edit menu forwards to the editor renderer. */
export type EditorUndoRedoChannel = "menu-undo" | "menu-redo";

export interface EditMenuOptions {
/** Localised label for `key`, falling back to `fallback` when untranslated. */
label: (key: string, fallback: string) => string;
/** Route an undo/redo request to whichever window should service it. */
dispatch: (channel: EditorUndoRedoChannel) => void;
}

/** The slice of `WebContents` the routing below touches. */
export interface UndoRedoWebContents {
send: (channel: EditorUndoRedoChannel) => void;
undo: () => void;
redo: () => void;
}

/** The slice of `BrowserWindow` the routing below touches. */
export interface UndoRedoWindow {
isDestroyed: () => boolean;
webContents: UndoRedoWebContents;
}

/**
* Deliver an undo/redo request to the window that should service it.
*
* Lives here rather than in `main.ts` for the reason `about.ts` does: `main.ts`
* calls `app.requestSingleInstanceLock()` at import time and cannot be loaded by a
* test, so anything left in it is untested by construction.
*
* Three cases, and the middle one is the whole reason the menu items dropped their
* `role`. There is no window, or it is gone: nothing to do — unlike
* `sendEditorMenuAction` this never CREATES an editor window, because Cmd+Z is not
* a request to open the editor. The window is not the editor — the launch window,
* the notes window — so the web-editing undo the `undo` role used to provide is the
* right one after all, and it is reached directly. Otherwise it is the editor, and
* the editor's renderer owns the document's undo stack.
*
* `isEditor` is a callback, not a flag, so nothing reads the window's URL before
* the destroyed check has run.
*/
export function routeEditorUndoRedo(
channel: EditorUndoRedoChannel,
window: UndoRedoWindow | null | undefined,
isEditor: () => boolean,
): void {
if (!window || window.isDestroyed()) return;
if (!isEditor()) {
if (channel === "menu-undo") window.webContents.undo();
else window.webContents.redo();
return;
}
window.webContents.send(channel);
}

export function buildEditMenuSubmenu({
label,
dispatch,
}: EditMenuOptions): MenuItemConstructorOptions[] {
return [
{
label: label("actions.undo", "Undo"),
accelerator: "CmdOrCtrl+Z",
click: () => dispatch("menu-undo"),
},
{
label: label("actions.redo", "Redo"),
accelerator: "Shift+CmdOrCtrl+Z",
click: () => dispatch("menu-redo"),
},
{ type: "separator" },
// The clipboard roles keep theirs: they act on the focused text selection,
// which is precisely what `webContents.cut/copy/paste` do, and the editor has
// no document-level meaning for them to shadow.
{ role: "cut", label: label("actions.cut", "Cut") },
{ role: "copy", label: label("actions.copy", "Copy") },
{ role: "paste", label: label("actions.paste", "Paste") },
{ role: "selectAll", label: label("actions.selectAll", "Select All") },
];
}
4 changes: 4 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ interface Window {
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
/** Edit > Undo / Redo. On macOS the menu is the only route Cmd+Z has to the
* renderer at all — see `electron/edit-menu.ts`. */
onMenuUndo: (callback: () => void) => () => void;
onMenuRedo: (callback: () => void) => () => void;
quitApp: () => void;
setTitleBarOverlay: (color: string, symbolColor: string) => void;
getPlatform: () => string;
Expand Down
27 changes: 15 additions & 12 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { parseCliArgs } from "./cli/args";
import { runCli } from "./cli/cliMain";
import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer";
import { buildEditMenuSubmenu, type EditorUndoRedoChannel, routeEditorUndoRedo } from "./edit-menu";
import {
loadAndRegisterGlobalShortcut,
registerOpenAppShortcut,
Expand Down Expand Up @@ -189,6 +190,15 @@ function sendEditorMenuAction(
targetWindow.webContents.send(channel);
}

/**
* Resolve which window the Edit menu's Undo/Redo is aimed at. The routing itself
* is `routeEditorUndoRedo`, in `edit-menu.ts`, where a test can reach it.
*/
function sendEditorUndoRedo(channel: EditorUndoRedoChannel) {
const targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
routeEditorUndoRedo(channel, targetWindow, () => !!targetWindow && isEditorWindow(targetWindow));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function setupApplicationMenu() {
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [];
Expand Down Expand Up @@ -274,18 +284,11 @@ function setupApplicationMenu() {
},
{
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
{ type: "separator" },
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
{
role: "selectAll",
label: mainT("common", "actions.selectAll") || "Select All",
},
],
// Built in `edit-menu.ts` — read its header for why Undo/Redo are not roles.
submenu: buildEditMenuSubmenu({
label: (key, fallback) => mainT("common", key) || fallback,
dispatch: sendEditorUndoRedo,
}),
},
{
label: mainT("common", "actions.view") || "View",
Expand Down
14 changes: 14 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,20 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("menu-save-project-as", listener);
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
},
// The Edit menu's Undo/Redo. On macOS this is the ONLY way Cmd+Z reaches the
// renderer: AppKit matches the menu's key equivalent before the key event is
// delivered to the web contents, so the document-level keydown handler never
// runs. See `electron/edit-menu.ts`.
onMenuUndo: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-undo", listener);
return () => ipcRenderer.removeListener("menu-undo", listener);
},
onMenuRedo: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-redo", listener);
return () => ipcRenderer.removeListener("menu-redo", listener);
},
quitApp: () => {
ipcRenderer.send("app-quit");
},
Expand Down
11 changes: 7 additions & 4 deletions src/components/ai-edition/CaptionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,13 @@ export function CaptionsPane() {
const clearLegacyCaptionAnnotations = async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
await saveDocument({
...doc,
annotations: doc.annotations.filter((a) => a.annotationSource !== "auto-caption"),
});
await saveDocument(
{
...doc,
annotations: doc.annotations.filter((a) => a.annotationSource !== "auto-caption"),
},
{ history: true },
);
};

return (
Expand Down
Loading
Loading