diff --git a/electron/edit-menu.test.ts b/electron/edit-menu.test.ts new file mode 100644 index 00000000..3b547895 --- /dev/null +++ b/electron/edit-menu.test.ts @@ -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(); + }); +}); diff --git a/electron/edit-menu.ts b/electron/edit-menu.ts new file mode 100644 index 00000000..a01b8729 --- /dev/null +++ b/electron/edit-menu.ts @@ -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") }, + ]; +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 89494dd0..4eb288e1 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/main.ts b/electron/main.ts index 9992a27c..85eb063b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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, @@ -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)); +} + function setupApplicationMenu() { const isMac = process.platform === "darwin"; const template: Electron.MenuItemConstructorOptions[] = []; @@ -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", diff --git a/electron/preload.ts b/electron/preload.ts index 2705e4ba..6aff1640 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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"); }, diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx index 2bcd8c5a..983852f1 100644 --- a/src/components/ai-edition/CaptionsPane.tsx +++ b/src/components/ai-edition/CaptionsPane.tsx @@ -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 ( diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 4026266c..64987dea 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -160,8 +160,14 @@ export function NewEditorShell() { [transcriptions], ); const tl = useTimeline(); - useUndoRedoShortcuts(() => { - // ponytail: placeholder, wire when undo stack merges with history + // An undo only puts the restored document back in the store and marks it dirty, + // so without this the reverted state never reached disk: close the window and the + // edit the user just undid came back. `history: false` is load-bearing — a + // recording save here would push the restored document straight back onto the + // stack and clear the redo the undo had just created. + const { runUndo, runRedo } = useUndoRedoShortcuts(() => { + const doc = useProjectStore.getState().document; + if (doc) void useProjectStore.getState().saveDocument(doc, { history: false }); }); const [copiedClipId, setCopiedClipId] = useState(null); const [projectSummaries, setProjectSummaries] = useState([]); @@ -311,7 +317,7 @@ export function NewEditorShell() { const doc = useProjectStore.getState().document; // The store already toasted the reason; answering false is what keeps the // window open on top of it. - if (doc) return await saveDocument(doc); + if (doc) return await saveDocument(doc, { history: true }); return true; }); @@ -368,7 +374,10 @@ export function NewEditorShell() { [{ startSec: 0, endSec: known }], "Auto-created full-duration clip", ); - void state.saveDocument(next); + // `history: false` for both writes in this callback: they are the probed + // duration being folded into the document on load, not something the user + // did — an undo landing on one of them would empty their timeline. + void state.saveDocument(next, { history: false }); return; } // Hand the probed duration to the pure document layer: it patches only the @@ -379,7 +388,7 @@ export function NewEditorShell() { // nothing is waiting, so there is nothing to guard here. const next = applyProbedDuration(doc, assetId, known); if (next !== doc) { - void state.saveDocument(next); + void state.saveDocument(next, { history: false }); } }, [setSourceDuration], @@ -566,25 +575,31 @@ export function NewEditorShell() { (target: TrimTarget, startSec: number, endSec: number, reason: string) => { // `clipId` is what keeps the cut on the block the user typed in: with two clips // over the same media, an asset-only trim showed up on both (see `trimAppliesToClip`). - void applyTimelineOp({ - type: "add_trim_range", - assetId: target.assetId, - clipId: target.clipId, - startSec, - endSec, - reason, - }); + void applyTimelineOp( + { + type: "add_trim_range", + assetId: target.assetId, + clipId: target.clipId, + startSec, + endSec, + reason, + }, + { history: true }, + ); }, [applyTimelineOp], ); const handleRemoveTrimRange = useCallback( (trimId: string) => { - void applyTimelineOp({ - type: "remove_trim_range", - trimId, - reason: "Restored from transcript pane.", - }); + void applyTimelineOp( + { + type: "remove_trim_range", + trimId, + reason: "Restored from transcript pane.", + }, + { history: true }, + ); }, [applyTimelineOp], ); @@ -642,15 +657,20 @@ export function NewEditorShell() { const handleSave = useCallback(async () => { const doc = useProjectStore.getState().document; if (!doc) return; - if (await saveDocument(doc)) toast.success("Project saved"); + if (await saveDocument(doc, { history: true })) toast.success("Project saved"); }, [saveDocument]); // Native File menu (electron/main.ts) → v4 actions. The menu is shown via // Menu.setApplicationMenu and dispatches these IPC events; the old editor // listened to them, but the v4 shell replaced it, leaving the File items // dead. Wire them to the same handlers the top-bar buttons use so the - // File/Edit/View menu bar works again (Edit/View items use Electron roles). + // File/Edit/View menu bar works again (the View items still use Electron roles). // The v4 editor has no separate "Save As" location, so it maps to Save. + // + // Edit > Undo/Redo are here too, and not roles: on macOS the menu's Cmd+Z key + // equivalent is matched by AppKit before the key event reaches the renderer, so + // this subscription is the ONLY thing that makes Ctrl+Z work there. See + // `electron/edit-menu.ts`. useEffect(() => { const api = window.electronAPI; if (!api) return; @@ -659,18 +679,20 @@ export function NewEditorShell() { api.onMenuLoadProject?.(() => setOpenProjectOpen(true)), api.onMenuSaveProject?.(() => void handleSave()), api.onMenuSaveProjectAs?.(() => void handleSave()), + api.onMenuUndo?.(runUndo), + api.onMenuRedo?.(runRedo), ]; return () => { for (const unsub of unsubscribers) unsub?.(); }; - }, [handleSave]); + }, [handleSave, runUndo, runRedo]); const handleRenameProject = useCallback( async (title: string) => { const doc = useProjectStore.getState().document; if (!doc) return; if (title === doc.project.title) return; - await saveDocument({ ...doc, project: { ...doc.project, title } }); + await saveDocument({ ...doc, project: { ...doc.project, title } }, { history: true }); }, [saveDocument], ); @@ -694,7 +716,7 @@ export function NewEditorShell() { // A failed save cancels the action that prompted this dialog. The store has // already said why -- which is what the bare `catch {}` here used to swallow, // leaving the window refusing to close with nothing on screen explaining it. - if (doc && !(await saveDocument(doc))) { + if (doc && !(await saveDocument(doc, { history: true }))) { resolve("cancel"); return; } @@ -788,24 +810,33 @@ export function NewEditorShell() { ); if (snapshot.kind === "zoom") { - await saveDocument({ - ...doc, - zoomRanges: [...doc.zoomRanges, ...anchored] as typeof doc.zoomRanges, - }); + await saveDocument( + { + ...doc, + zoomRanges: [...doc.zoomRanges, ...anchored] as typeof doc.zoomRanges, + }, + { history: true }, + ); } else if (snapshot.kind === "annotation") { - await saveDocument({ - ...doc, - annotations: [...doc.annotations, ...anchored] as typeof doc.annotations, - }); + await saveDocument( + { + ...doc, + annotations: [...doc.annotations, ...anchored] as typeof doc.annotations, + }, + { history: true }, + ); } else { // speed and cameraFullscreen are both plain spans on legacyEditor. const key = snapshot.kind === "speed" ? "speedRegions" : "cameraFullscreenRegions"; const legacy = (doc.legacyEditor as Record) ?? {}; const prev = (legacy[key] as unknown[]) ?? []; - await saveDocument({ - ...doc, - legacyEditor: { ...legacy, [key]: [...prev, ...anchored] }, - }); + await saveDocument( + { + ...doc, + legacyEditor: { ...legacy, [key]: [...prev, ...anchored] }, + }, + { history: true }, + ); } toast.success("Region pasted"); // `tl` belongs here now that the trim branch calls tl.addTrim: useTimeline @@ -880,7 +911,7 @@ export function NewEditorShell() { if (choice === "save") { const doc = useProjectStore.getState().document; // Stay put if the save did not land -- the store has already said why. - if (doc && !(await saveDocument(doc))) return; + if (doc && !(await saveDocument(doc, { history: true }))) return; } setNewProjectOpen(true); })(); @@ -894,7 +925,7 @@ export function NewEditorShell() { if (choice === "save") { const doc = useProjectStore.getState().document; // Stay put if the save did not land -- the store has already said why. - if (doc && !(await saveDocument(doc))) return; + if (doc && !(await saveDocument(doc, { history: true }))) return; } setOpenProjectOpen(true); })(); diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts index 2c9728d4..4faea3d5 100644 --- a/src/components/ai-edition/recordingImport.test.ts +++ b/src/components/ai-edition/recordingImport.test.ts @@ -1,16 +1,34 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; +import { replaceTimeline as replaceTimelineOp } from "@/lib/ai-edition/document/timeline"; +import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { undo } from "@/lib/ai-edition/store/undo"; +import { clearHistory, past } from "@/lib/ai-edition/store/undoStack"; import { importPendingRecording } from "./recordingImport"; -// The store's own bridge calls are never reached — every action the import uses -// is stubbed below — but importing the store pulls the client in, so stub it. -vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); +// The first describe stubs the store actions, so the bridge is never reached +// there. The second one runs the REAL store against these, which is the only way +// to see what the import leaves on the undo stack. +const bridge = vi.hoisted(() => ({ + create: vi.fn(), + addAsset: vi.fn(), + save: vi.fn(), +})); +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: bridge } })); const createProject = vi.fn(async () => undefined); const addAsset = vi.fn(async () => null); const replaceTimeline = vi.fn(async () => undefined); +// Read before anything stubs them: the first describe replaces these actions on the +// live store, and `clear()` resets the DATA, not the actions. +const realActions = { + createProject: useProjectStore.getState().createProject, + addAsset: useProjectStore.getState().addAsset, + replaceTimeline: useProjectStore.getState().replaceTimeline, +}; + /** Stands in for the main-process recording slot: one value, set and read. */ function stubElectronApi(screenVideoPath: string | null) { let session = screenVideoPath ? { screenVideoPath, createdAt: 0 } : null; @@ -86,6 +104,86 @@ describe("importPendingRecording", () => { expect(replaceTimeline).toHaveBeenCalledWith( [{ startSec: 0, endSec: 60 }], "Auto-imported recording", + { history: false }, ); }); }); + +// The whole hand-off, against the real store: stop the recording, land in the +// editor, press Ctrl+Z. +// +// The user has made no edit at this point -- the editor built this project for +// them, unattended, on mount. The seed below used to record itself as an undo +// step because `projectStore.replaceTimeline` hardcoded `{ history: true }` inside +// itself, where the option was invisible to its caller. So a brand-new project +// opened with `past.length === 1`, the first Ctrl+Z restored the state before the +// seed -- an empty timeline -- and `NewEditorShell`'s post-undo persist wrote that +// empty timeline to disk. +describe("what the recording import leaves on the undo stack", () => { + const PROJECT_ID = "project_imported"; + const SCREEN_PATH = "/recordings/recording-1.webm"; + + /** The document the main process actually returns from `addAsset`: an asset with + * no `durationSec` (it stats the file, it does not probe it). */ + function withAsset(): AxcutDocument { + const doc = createEmptyDocument({ projectId: PROJECT_ID, title: "Recording" }); + return { + ...doc, + assets: [ + { + id: "asset_1", + kind: "video", + label: "recording-1.webm", + originalPath: SCREEN_PATH, + cameraTrack: null, + }, + ], + project: { ...doc.project, primaryAssetId: "asset_1" }, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + useProjectStore.getState().clear(); + useProjectStore.setState(realActions); + clearHistory(); + bridge.create.mockImplementation(async () => ({ + success: true, + document: createEmptyDocument({ projectId: PROJECT_ID, title: "Recording" }), + })); + bridge.addAsset.mockImplementation(async () => ({ success: true, document: withAsset() })); + bridge.save.mockImplementation(async (document: unknown) => ({ success: true, document })); + stubElectronApi(SCREEN_PATH); + }); + + it("leaves it empty: the user has not edited anything yet", async () => { + await importPendingRecording(); + + expect(past).toHaveLength(0); + expect(undo()).toBe(false); + }); + + it("still has its clip after the first Ctrl+Z", async () => { + await importPendingRecording(); + + // The `