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
65 changes: 3 additions & 62 deletions src/components/ai-edition/EditorEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string | null> => {
const existing = useProjectStore.getState().projectId;
Expand All @@ -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<boolean> => {
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 {
Expand All @@ -104,41 +80,6 @@ export function EditorEmptyState({
}
}, [openLoadedProject]);

// A document handed in from outside the app — `openscreen open <file>`, 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) {
Expand Down
7 changes: 7 additions & 0 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,6 +91,12 @@ function NativePlaybackSync({

export function NewEditorShell() {
const te = useScopedT("editor");

// Documents handed in from outside — the Studio, `openscreen open <file>`, 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);
Expand Down
125 changes: 125 additions & 0 deletions src/components/ai-edition/useOpenProjectFile.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="host" />;
}

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(<Host />);
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(<Host />);
await waitFor(() => expect(mocks.loadProject).toHaveBeenCalledWith("proj_feeney"));
});
});
97 changes: 97 additions & 0 deletions src/components/ai-edition/useOpenProjectFile.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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 <file>`, 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]);
}
Loading