diff --git a/src/components/ai-edition/EditorEmptyState.tsx b/src/components/ai-edition/EditorEmptyState.tsx index b94032d49..42667b025 100644 --- a/src/components/ai-edition/EditorEmptyState.tsx +++ b/src/components/ai-edition/EditorEmptyState.tsx @@ -11,17 +11,12 @@ // so this is the single render path for the feature. import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; -import { - migrateProjectDataToAxcutDocument, - migrateRawDocumentToCurrent, -} from "@/lib/ai-edition/document/migrate"; -import { documentSchema } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; -import { nativeBridgeClient } from "@/native"; import styles from "./NewEditorShell.module.css"; +import { useOpenLoadedProject } from "./useOpenProjectFile"; type DropError = "unsupported-format" | "load-failed" | null; @@ -47,7 +42,6 @@ export function EditorEmptyState({ const createProject = useProjectStore((s) => s.createProject); const addAsset = useProjectStore((s) => s.addAsset); - const loadProject = useProjectStore((s) => s.loadProject); const ensureProject = useCallback(async (): Promise => { const existing = useProjectStore.getState().projectId; @@ -72,25 +66,7 @@ export function EditorEmptyState({ } }, [addAsset, ensureProject]); - // A loaded project JSON is either a current AxcutDocument (has its own - // `schemaVersion`) or a legacy EditorProjectData that must be migrated. - // Discriminate on the version field so a current document is never fed to - // the legacy migrator (which reads `.media`/`.editor` and would yield an - // empty doc). Returns true once the project is saved and loaded. - const openLoadedProject = useCallback( - async (raw: unknown): Promise => { - const isAxcutDocument = - typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw; - const doc = isAxcutDocument - ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate - : migrateProjectDataToAxcutDocument(raw as never); - const saved = await nativeBridgeClient.aiEdition.save(doc); - if (!saved.success || !saved.document) return false; - await loadProject(doc.project.id); - return true; - }, - [loadProject], - ); + const openLoadedProject = useOpenLoadedProject(); const handleLoadProject = useCallback(async () => { try { @@ -104,41 +80,6 @@ export function EditorEmptyState({ } }, [openLoadedProject]); - // A document handed in from outside the app — `openscreen open `, a file - // association, or a second launch with a path. Same two steps the drop handler - // takes, because it is the same job: read the file, then open what came back. - const openFromPath = useCallback( - async (filePath: string) => { - try { - const result = await window.electronAPI?.loadProjectFileFromPath?.(filePath); - if (!result?.success || !result.project) { - setDropError("load-failed"); - return; - } - if (!(await openLoadedProject(result.project))) setDropError("load-failed"); - } catch { - setDropError("load-failed"); - } - }, - [openLoadedProject], - ); - - // Two arrival routes, because a document can be handed over before or after - // this component exists. - // - // Asking is the one that matters at launch: the main process parks the path - // rather than pushing it, since `did-finish-load` fires before React mounts and - // a pushed message would land on nobody. Listening covers the other case — an - // app already open when a second `openscreen open` arrives. - useEffect(() => { - const api = window.electronAPI; - if (!api) return; - void api.takePendingOpenPath?.().then((filePath) => { - if (filePath) void openFromPath(filePath); - }); - return api.onOpenProjectPath?.((filePath: string) => void openFromPath(filePath)); - }, [openFromPath]); - const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); if (e.dataTransfer.items.length > 0) { diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 64987dea9..065bc2396 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -44,6 +44,7 @@ import { import { Preview } from "./Preview"; import type { TrimTarget } from "./RightPanes"; import { importPendingRecording } from "./recordingImport"; +import { useIncomingProjectPath } from "./useOpenProjectFile"; import v4 from "./v4/EditorShellV4.module.css"; import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar"; import { type Facet, FloatingInspector } from "./v4/FloatingInspector"; @@ -90,6 +91,12 @@ function NativePlaybackSync({ export function NewEditorShell() { const te = useScopedT("editor"); + + // Documents handed in from outside — the Studio, `openscreen open `, a + // file association. Mounted here rather than in EditorEmptyState because the + // empty state unmounts as soon as a document is open, which silently dropped + // every hand-over after the first (see useOpenProjectFile). + useIncomingProjectPath(); const document = useProjectStore((s) => s.document); const projectId = useProjectStore((s) => s.projectId); const dirty = useProjectStore((s) => s.dirty); diff --git a/src/components/ai-edition/useOpenProjectFile.test.tsx b/src/components/ai-edition/useOpenProjectFile.test.tsx new file mode 100644 index 000000000..ab64fcda3 --- /dev/null +++ b/src/components/ai-edition/useOpenProjectFile.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +// +// Regression cover for hand-overs from outside the editor. +// +// The bug this pins: the subscription used to live in EditorEmptyState, which +// unmounts the moment a document is open. The first hand-over worked and every +// one after it was dropped, so clicking a video in the Studio "succeeded" while +// the editor went on showing the previously opened document. +import "@testing-library/jest-dom"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; +import { useIncomingProjectPath } from "./useOpenProjectFile"; + +const mocks = vi.hoisted(() => ({ + save: vi.fn(), + loadProject: vi.fn(), + loadProjectFileFromPath: vi.fn(), + takePendingOpenPath: vi.fn(), + onOpenProjectPath: vi.fn(), +})); + +vi.mock("@/native", () => ({ + nativeBridgeClient: { aiEdition: { save: mocks.save } }, +})); + +vi.mock("@/lib/ai-edition/store/projectStore", () => ({ + useProjectStore: (select: (s: unknown) => unknown) => select({ loadProject: mocks.loadProject }), +})); + +// A document is handed over by path; what comes back off disk is a current +// document, so the loader validates rather than migrates it. +function docWithId(id: string): AxcutDocument { + return { + schemaVersion: 7, + project: { + id, + title: id, + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T10:00:00.000Z", + }, + assets: [], + transcript: null, + transcripts: [], + timeline: { + clips: [], + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + annotations: [], + zoomRanges: [], + legacyEditor: null, + } as AxcutDocument; +} + +vi.mock("@/lib/ai-edition/schema", () => ({ + documentSchema: { parse: (d: unknown) => d }, +})); +vi.mock("@/lib/ai-edition/document/migrate", () => ({ + migrateRawDocumentToCurrent: (d: unknown) => d, + migrateProjectDataToAxcutDocument: (d: unknown) => d, +})); + +function Host() { + useIncomingProjectPath(); + return
; +} + +describe("useIncomingProjectPath", () => { + let push: ((filePath: string) => void) | null = null; + + beforeEach(() => { + for (const m of Object.values(mocks)) m.mockReset(); + push = null; + mocks.takePendingOpenPath.mockResolvedValue(null); + mocks.onOpenProjectPath.mockImplementation((cb: (p: string) => void) => { + push = cb; + return () => { + push = null; + }; + }); + mocks.loadProjectFileFromPath.mockImplementation(async (p: string) => ({ + success: true, + project: docWithId(p.includes("feeney") ? "proj_feeney" : "proj_guides"), + })); + mocks.save.mockImplementation(async (doc: AxcutDocument) => ({ success: true, document: doc })); + Object.assign(window, { + electronAPI: { + loadProjectFileFromPath: mocks.loadProjectFileFromPath, + takePendingOpenPath: mocks.takePendingOpenPath, + onOpenProjectPath: mocks.onOpenProjectPath, + }, + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("opens a second hand-over, not the one already open", async () => { + render(); + await waitFor(() => expect(mocks.onOpenProjectPath).toHaveBeenCalled()); + + await act(async () => push?.("/tmp/feeney.openscreen")); + await waitFor(() => expect(mocks.loadProject).toHaveBeenCalledWith("proj_feeney")); + + // The editor now has a document open. In the old arrangement the empty + // state had unmounted by this point and this push reached nobody. + await act(async () => push?.("/tmp/ai-guides.openscreen")); + await waitFor(() => expect(mocks.loadProject).toHaveBeenCalledWith("proj_guides")); + + expect(mocks.loadProject).toHaveBeenCalledTimes(2); + expect(mocks.loadProject).toHaveBeenLastCalledWith("proj_guides"); + }); + + it("opens a document parked before the listener existed", async () => { + mocks.takePendingOpenPath.mockResolvedValue("/tmp/feeney.openscreen"); + render(); + await waitFor(() => expect(mocks.loadProject).toHaveBeenCalledWith("proj_feeney")); + }); +}); diff --git a/src/components/ai-edition/useOpenProjectFile.ts b/src/components/ai-edition/useOpenProjectFile.ts new file mode 100644 index 000000000..e1400722e --- /dev/null +++ b/src/components/ai-edition/useOpenProjectFile.ts @@ -0,0 +1,97 @@ +// Opening a project document that arrives from outside the editor. +// +// This lives outside EditorEmptyState because the empty state is exactly the +// component that goes away once a document is open: it renders in the `else` +// branch of Preview's "is there media" test, so mounting the arrival routes +// inside it meant they were subscribed only while the editor was empty. The +// first hand-over worked; every one after it landed on an unmounted listener +// and the editor went on showing the previous document. Symptom from the +// Studio side was the worst kind — clicking a video "worked" and opened +// somebody else's deck. +// +// So: the loader is a hook the empty state uses for its own drop/picker, and +// the subscription is a separate hook mounted by the shell, which stays up for +// the life of the window. + +import { useCallback, useEffect } from "react"; +import { + migrateProjectDataToAxcutDocument, + migrateRawDocumentToCurrent, +} from "@/lib/ai-edition/document/migrate"; +import { documentSchema } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { nativeBridgeClient } from "@/native"; + +/** + * Save a loaded project document and make it the open one. + * + * A loaded project JSON is either a current AxcutDocument (has its own + * `schemaVersion`) or a legacy EditorProjectData that must be migrated. + * Discriminate on the version field so a current document is never fed to the + * legacy migrator (which reads `.media`/`.editor` and would yield an empty + * doc). Returns true once the project is saved and loaded. + */ +export function useOpenLoadedProject() { + const loadProject = useProjectStore((s) => s.loadProject); + return useCallback( + async (raw: unknown): Promise => { + const isAxcutDocument = + typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw; + const doc = isAxcutDocument + ? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate + : migrateProjectDataToAxcutDocument(raw as never); + const saved = await nativeBridgeClient.aiEdition.save(doc); + if (!saved.success || !saved.document) return false; + await loadProject(doc.project.id); + return true; + }, + [loadProject], + ); +} + +/** + * Read a document off disk and open it. Same two steps the drop handler takes, + * because it is the same job: read the file, then open what came back. + */ +export function useOpenProjectFromPath(onError?: () => void) { + const openLoadedProject = useOpenLoadedProject(); + return useCallback( + async (filePath: string) => { + try { + const result = await window.electronAPI?.loadProjectFileFromPath?.(filePath); + if (!result?.success || !result.project) { + onError?.(); + return; + } + if (!(await openLoadedProject(result.project))) onError?.(); + } catch { + onError?.(); + } + }, + [openLoadedProject, onError], + ); +} + +/** + * Subscribe to documents handed in from outside — `openscreen open `, a + * file association, a second launch with a path, or the Studio handing over the + * video the user just clicked. + * + * Two arrival routes, because a document can be handed over before or after + * this hook exists. Asking is the one that matters at launch: the main process + * parks the path rather than pushing it, since `did-finish-load` fires before + * React mounts and a pushed message would land on nobody. Listening covers + * every hand-over after that, which is why this must be mounted by something + * that outlives the empty state. + */ +export function useIncomingProjectPath(onError?: () => void) { + const openFromPath = useOpenProjectFromPath(onError); + useEffect(() => { + const api = window.electronAPI; + if (!api) return; + void api.takePendingOpenPath?.().then((filePath) => { + if (filePath) void openFromPath(filePath); + }); + return api.onOpenProjectPath?.((filePath: string) => void openFromPath(filePath)); + }, [openFromPath]); +}