diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 6aa31e74e..526a5a12d 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -870,21 +870,6 @@ describe("DocumentService", () => { expect((await service.getProject(created.project.id)).project.title).toBe("Synced"); }); - it("counts the open-file dialog reading the project's own file as a read", async () => { - const created = await service.createProject("Browsed"); - const { document, version } = await service.getProjectForUpdate(created.project.id); - await service.beforeProjectFileRead(path.join(tempDir, `${created.project.id}.openscreen`)); - expect(await service.saveProjectIfUnchanged(renamed(document, "Edited"), version)).toBeNull(); - }); - - it("ignores the open-file dialog reading a file outside the projects folder", async () => { - const created = await service.createProject("Elsewhere"); - const { document, version } = await service.getProjectForUpdate(created.project.id); - await service.beforeProjectFileRead(path.join(mediaDir, `${created.project.id}.openscreen`)); - const saved = await service.saveProjectIfUnchanged(renamed(document, "Edited"), version); - expect(saved?.project.title).toBe("Edited"); - }); - it("writes nothing over a project deleted since", async () => { const created = await service.createProject("Deleted"); const { document, version } = await service.getProjectForUpdate(created.project.id); @@ -893,4 +878,19 @@ describe("DocumentService", () => { await expect(service.getProject(created.project.id)).rejects.toThrow(DocumentNotFoundError); }); }); + + describe("storedProjectId", () => { + it("names the project a file in the projects folder is the own file of", () => { + expect(service.storedProjectId(path.join(tempDir, "proj_a.openscreen"))).toBe("proj_a"); + expect(service.storedProjectId(path.join(tempDir, "proj_a.axcut"))).toBe("proj_a"); + }); + + it("names nothing for a file anywhere else", () => { + expect(service.storedProjectId(path.join(mediaDir, "proj_a.openscreen"))).toBeUndefined(); + expect( + service.storedProjectId(path.join(tempDir, "sub", "proj_a.openscreen")), + ).toBeUndefined(); + expect(service.storedProjectId(path.join(tempDir, "proj_a.json"))).toBeUndefined(); + }); + }); }); diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index bcbee41c4..1228e728d 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -336,21 +336,19 @@ export class DocumentService { } /** - * For a read of a project file that bypasses getProject: the open-file dialog, - * which then saves back what it read. If the file is one of this service's - * projects, that counts as a read of it, behind its queued reads and saves, as in - * getProject. Any other file is not this service's business. + * The id of the project `filePath` is the own file of, or undefined for any other + * file. The open-file dialog hands it to the editor, which then loads that project + * instead of saving back the file as read, over any change made since. */ - async beforeProjectFileRead(filePath: string): Promise { + storedProjectId(filePath: string): string | undefined { const resolved = path.resolve(filePath); - if (path.relative(path.resolve(this.projectsRoot), path.dirname(resolved)) !== "") return; + if (path.relative(path.resolve(this.projectsRoot), path.dirname(resolved)) !== "") { + return undefined; + } const extension = [PROJECT_FILE_EXTENSION, LEGACY_PROJECT_FILE_EXTENSION].find((ext) => resolved.endsWith(ext), ); - if (!extension) return; - const projectId = path.basename(resolved, extension); - this.touch(projectId); - await this.writeQueues.get(projectId); + return extension ? path.basename(resolved, extension) : undefined; } private touch(projectId: string): number { diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e17c8da97..6ad0694cd 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -427,6 +427,7 @@ interface Window { success: boolean; path?: string; project?: unknown; + storedProjectId?: string; message?: string; canceled?: boolean; error?: string; @@ -444,6 +445,7 @@ interface Window { success: boolean; path?: string; project?: unknown; + storedProjectId?: string; message?: string; canceled?: boolean; error?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 78a370d95..b04f3ec92 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -4718,8 +4718,6 @@ export function registerIpcHandlers( } const filePath = result.filePaths[0]; - // The editor saves back what it opened here: see beforeProjectFileRead. - await aiEditionDocuments.beforeProjectFileRead(filePath); const content = await fs.readFile(filePath, "utf-8"); const project = await relinkProjectMedia(JSON.parse(content), DEFAULT_RECORDINGS_DIR); currentProjectPath = filePath; @@ -4738,6 +4736,7 @@ export function registerIpcHandlers( success: true, path: filePath, project, + storedProjectId: aiEditionDocuments.storedProjectId(filePath), }; } catch (error) { console.error("Failed to load project file:", error); @@ -4766,7 +4765,6 @@ export function registerIpcHandlers( if (!stats?.isFile()) { return { success: false, message: "File not found" }; } - await aiEditionDocuments.beforeProjectFileRead(filePath); const content = await fs.readFile(filePath, "utf-8"); const project = await relinkProjectMedia(JSON.parse(content), DEFAULT_RECORDINGS_DIR); currentProjectPath = filePath; @@ -4796,7 +4794,12 @@ export function registerIpcHandlers( ); } setCurrentRecordingSessionState(session); - return { success: true, path: filePath, project }; + return { + success: true, + path: filePath, + project, + storedProjectId: aiEditionDocuments.storedProjectId(filePath), + }; } catch (error) { console.error("Failed to load project file from path:", error); return { diff --git a/src/components/ai-edition/EditorEmptyState.tsx b/src/components/ai-edition/EditorEmptyState.tsx index 2bd903546..b2a5a6e47 100644 --- a/src/components/ai-edition/EditorEmptyState.tsx +++ b/src/components/ai-edition/EditorEmptyState.tsx @@ -14,13 +14,8 @@ import { AlertCircle, Circle, Film, FolderOpen, Upload, X } from "lucide-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 { importProjectFile } from "./importProjectFile"; import styles from "./NewEditorShell.module.css"; type DropError = "unsupported-format" | "load-failed" | null; @@ -77,22 +72,11 @@ 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. + // What the open-file dialog or a drop read: see importProjectFile. 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; + async (file: { project?: unknown; storedProjectId?: string }) => { + const { projectId } = await importProjectFile(file.project, file.storedProjectId); + await loadProject(projectId); }, [loadProject], ); @@ -101,9 +85,7 @@ export function EditorEmptyState({ try { const result = await window.electronAPI?.loadProjectFile?.(); if (!result?.success || !result.project) return; - if (!(await openLoadedProject(result.project))) { - setDropError("load-failed"); - } + await openLoadedProject(result); } catch { setDropError("load-failed"); } @@ -150,9 +132,7 @@ export function EditorEmptyState({ return; } try { - if (!(await openLoadedProject(result.project))) { - setDropError("load-failed"); - } + await openLoadedProject(result); } catch { setDropError("load-failed"); } diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index ee12161bd..08b52f21e 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import type { EditorProjectData } from "@/components/video-editor/projectPersistence"; import { toFileUrl } from "@/components/video-editor/projectPersistence"; import { useEditorDialogActions } from "@/contexts/EditorDialogsContext"; import { useScopedT } from "@/contexts/I18nContext"; @@ -10,10 +9,6 @@ import { computeAudioRowCount, } from "@/lib/ai-edition/document/audioTracks"; import { createId } from "@/lib/ai-edition/document/ids"; -import { - migrateProjectDataToAxcutDocument, - migrateRawDocumentToCurrent, -} from "@/lib/ai-edition/document/migrate"; import { documentAfterProbedDuration, PLACEHOLDER_DURATION_SEC, @@ -25,12 +20,7 @@ import { setDocumentWordText, } from "@/lib/ai-edition/document/transcript"; import { isModalOpen } from "@/lib/ai-edition/modalGuard"; -import { - type AxcutAudioTrack, - type AxcutClip, - type AxcutDocument, - documentSchema, -} from "@/lib/ai-edition/schema"; +import type { AxcutAudioTrack, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; import { useMcpDocumentHost } from "@/lib/ai-edition/store/mcpDocumentHost"; import { saveWithDeadline, useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { @@ -59,6 +49,7 @@ import type { AiEditionProjectSummary } from "@/native/contracts"; import { resolveVisibleClips } from "@/native/sceneDescription"; import { useNativePlaybackSync } from "@/native/useNativePlaybackSync"; import { ExportDialog } from "./ExportDialog"; +import { importProjectFile } from "./importProjectFile"; import { insertionsEnabled } from "./insertionsEnabled"; import { ChatStripPanel } from "./LeftPanel"; import { @@ -650,24 +641,9 @@ export function NewEditorShell() { try { const result = await window.electronAPI?.loadProjectFile(); if (!result?.success || !result.project) return; - const raw = result.project as unknown; - // A current project file already carries its own `schemaVersion` (v3/v4 - // AxcutDocument); an older legacy export is EditorProjectData and 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). - 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 EditorProjectData); - const saved = await nativeBridgeClient.aiEdition.save(doc); - if (saved.success && saved.document) { - await loadProject(doc.project.id); - toast.success(isAxcutDocument ? "Project opened" : "Legacy project migrated and loaded"); - } else { - toast.error(saved.error ?? "Failed to open project"); - } + const { projectId, legacy } = await importProjectFile(result.project, result.storedProjectId); + await loadProject(projectId); + toast.success(legacy ? "Legacy project migrated and loaded" : "Project opened"); } catch (err) { toast.error("Could not load project", { description: err instanceof Error ? err.message : String(err), diff --git a/src/components/ai-edition/importProjectFile.test.ts b/src/components/ai-edition/importProjectFile.test.ts new file mode 100644 index 000000000..8e99b1086 --- /dev/null +++ b/src/components/ai-edition/importProjectFile.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema"; +import { DocumentService } from "../../../electron/ai-edition/document-service"; +import { importProjectFile } from "./importProjectFile"; + +// The bridge's save is the main process's: the real DocumentService, on a temp folder. +const bridge = vi.hoisted(() => ({ save: vi.fn() })); +vi.mock("@/native", () => ({ nativeBridgeClient: { aiEdition: bridge } })); + +describe("importProjectFile", () => { + let projectsDir: string; + let otherDir: string; + let service: DocumentService; + + beforeEach(async () => { + projectsDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-projects-")); + otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-elsewhere-")); + service = new DocumentService(projectsDir, otherDir); + bridge.save.mockImplementation(async (doc: AxcutDocument) => ({ + success: true, + document: await service.saveProject(doc), + })); + }); + + afterEach(async () => { + await fs.rm(projectsDir, { recursive: true, force: true }); + await fs.rm(otherDir, { recursive: true, force: true }); + }); + + it("keeps an MCP edit made between the dialog reading a project's own file and opening it", async () => { + const created = await service.createProject("As read"); + const file = path.join(projectsDir, `${created.project.id}.openscreen`); + const read: unknown = JSON.parse(await fs.readFile(file, "utf8")); + + const { document, version } = await service.getProjectForUpdate(created.project.id); + const edited = { ...document, project: { ...document.project, title: "Edited over MCP" } }; + expect(await service.saveProjectIfUnchanged(edited, version)).not.toBeNull(); + + const { projectId } = await importProjectFile(read, service.storedProjectId(file)); + + expect(projectId).toBe(created.project.id); + expect((await service.getProject(projectId)).project.title).toBe("Edited over MCP"); + }); + + it("saves a file from outside the projects folder as a project", async () => { + const doc = createEmptyDocument({ projectId: "proj_imported", title: "Imported" }); + const file = path.join(otherDir, "proj_imported.openscreen"); + + const { projectId } = await importProjectFile(doc, service.storedProjectId(file)); + + expect((await service.getProject(projectId)).project.title).toBe("Imported"); + }); +}); diff --git a/src/components/ai-edition/importProjectFile.ts b/src/components/ai-edition/importProjectFile.ts new file mode 100644 index 000000000..6c9718f11 --- /dev/null +++ b/src/components/ai-edition/importProjectFile.ts @@ -0,0 +1,43 @@ +// What the open-file dialog (or a drop) read, turned into a project the editor can load. + +import type { EditorProjectData } from "@/components/video-editor/projectPersistence"; +import { + migrateProjectDataToAxcutDocument, + migrateRawDocumentToCurrent, +} from "@/lib/ai-edition/document/migrate"; +import { documentSchema } from "@/lib/ai-edition/schema"; +import { nativeBridgeClient } from "@/native"; + +/** + * Saves `raw` as a project and returns its id, for the caller to load. + * + * A current project file carries its own `schemaVersion` (an AxcutDocument) and is upgraded; + * anything else is a legacy EditorProjectData and is 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). + * + * A project's own file (`storedProjectId`) is not saved: saving it back would write the file + * as read over any change made since, an MCP edit included. Loading it by id reads it again, + * as opening it from the project list does. + */ +export async function importProjectFile( + raw: unknown, + storedProjectId?: string, +): Promise<{ projectId: string; legacy: boolean }> { + const legacy = !( + typeof raw === "object" && + raw !== null && + "schemaVersion" in raw && + "timeline" in raw + ); + const doc = legacy + ? migrateProjectDataToAxcutDocument(raw as EditorProjectData) + : documentSchema.parse(migrateRawDocumentToCurrent(raw)); // disk-load: upgrade, then validate + if (doc.project.id !== storedProjectId) { + const saved = await nativeBridgeClient.aiEdition.save(doc); + if (!saved.success || !saved.document) { + throw new Error(saved.error ?? "Failed to open project"); + } + } + return { projectId: doc.project.id, legacy }; +} diff --git a/src/native/contracts.ts b/src/native/contracts.ts index cc76bb59b..9dcb031e3 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -91,6 +91,8 @@ export interface ProjectFileResult { success: boolean; path?: string; project?: unknown; + /** Set when the file is a stored project's own file: load that project, never save it back. */ + storedProjectId?: string; message?: string; canceled?: boolean; error?: string; diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 132b0fe19..5cebdfa35 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -56,7 +56,7 @@ A call naming a project reads its file **before** asking the editor for a snapsh There is no revision to guard the file with, so the save is refused with "NOT applied … re-read, then retry" when, since the call's read: -1. **Anything read the project.** This is the editor opening it: from that read on it holds a copy without the MCP edit, and its next save would drop the edit without a word. A snapshot cannot see this in time, since the editor installs what it read only after the read returns. `DocumentService` counts every read and save of a project instead, the open-file dialog's direct read of a project file included (`beforeProjectFileRead`). The client's retry, with the same `projectId`, then goes through the editor. +1. **Anything read the project.** This is the editor opening it: from that read on it holds a copy without the MCP edit, and its next save would drop the edit without a word. A snapshot cannot see this in time, since the editor installs what it read only after the read returns. `DocumentService` counts every read and save of a project instead. The client's retry, with the same `projectId`, then goes through the editor. The open-file dialog never saves back a project's own file: it loads that project by id (`storedProjectId`), a read like any other. 2. **Anything saved it, or its file changed.** The file must still be, byte for byte, the one the tool ran against. This also covers a writer outside the app (a sync tool, a restored copy) and a delete. The check runs inside the project's queue, so nothing in the app reads or saves the project between the check and the write. @@ -100,5 +100,4 @@ codex mcp add openscreen --url http://127.0.0.1:47821/mcp --bearer-token-env-var - **Only what the agent can do.** The server exposes the agent's timeline tools. Recording, export, import and project management are not tools, for MCP or for the in-app agent. - **No Ctrl+Z for edits to closed projects.** Their undo is the checkpoint the write requires, which lives in memory: it is gone once the app quits or 20 newer ones push it out, and the edits stay. - **An editor that stops answering looks closed.** A snapshot unanswered for 30 s reads as "no project open", so an edit naming the project that editor holds would go to its file, and the editor's next save would drop it. It takes a renderer hung for 30 s. -- **The open-file dialog saves back what it read.** Opening a project's own file through it reads the file, then saves that content as the project. An MCP edit landing between the read and that save is overwritten, as any other change would be. Closing it is the dialog's job (not saving back a file that already is the project), not the server's. - **No project management.** Projects can be listed, read and edited, not created, renamed or deleted.