diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 52e1bb97e6..178947a396 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -385,12 +385,21 @@ export class DocumentService { } /** Every project that parses, unrelinked and unannounced: a listing, not an open. */ - private async readAllProjects(): Promise { + async readAllProjects(): Promise { + return (await this.readAllProjectsReport()).documents; + } + + /** + * `readAllProjects`, plus the project files it skipped. A caller deciding whether no project + * uses a file needs those: a project that does not parse may still be using it. + */ + async readAllProjectsReport(): Promise<{ documents: AxcutDocument[]; unreadable: string[] }> { await this.ensureProjectsDir(); const entries = await fs.readdir(this.projectsRoot); // ensureProjectsDir (above) already migrated any legacy `.axcut` files. const projectFiles = entries.filter((name) => name.endsWith(PROJECT_FILE_EXTENSION)); const documents: AxcutDocument[] = []; + const unreadable: string[] = []; for (const name of projectFiles) { const filePath = path.join(this.projectsRoot, name); try { @@ -399,9 +408,10 @@ export class DocumentService { // ponytail: skip unreadable files rather than failing the whole list. // A future migration pass can recover them. console.warn(`[ai-edition] failed to read ${filePath}:`, error); + unreadable.push(filePath); } } - return documents; + return { documents, unreadable }; } /** diff --git a/electron/ai-edition/project-recordings.test.ts b/electron/ai-edition/project-recordings.test.ts new file mode 100644 index 0000000000..06c55e2f0b --- /dev/null +++ b/electron/ai-edition/project-recordings.test.ts @@ -0,0 +1,140 @@ +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 { DocumentService } from "./document-service"; +import { findProjectRecordings, removeRecordingSets } from "./project-recordings"; + +async function touch(filePath: string, content = "x"): Promise { + await fs.writeFile(filePath, content); + return filePath; +} + +describe("project recordings", () => { + let root: string; + let recordingsDir: string; + let elsewhere: string; + let documents: DocumentService; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-project-recordings-")); + recordingsDir = path.join(root, "recordings"); + elsewhere = path.join(root, "Desktop"); + await fs.mkdir(recordingsDir); + await fs.mkdir(elsewhere); + documents = new DocumentService(path.join(root, "projects"), root); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + async function projectWith(title: string, ...videoPaths: string[]): Promise { + const doc = await documents.createProject(title); + for (const videoPath of videoPaths) { + await documents.addAsset(doc.project.id, { + path: videoPath, + label: path.basename(videoPath), + }); + } + return doc.project.id; + } + + it("owns every file recorded beside the screen video", async () => { + const video = await touch(path.join(recordingsDir, "recording-12.mp4"), "12345"); + const companions = [ + await touch(`${video}.cursor.json`), + await touch(path.join(recordingsDir, "recording-12.session.json")), + await touch(path.join(recordingsDir, "recording-12-webcam.webm")), + ]; + await touch(path.join(recordingsDir, "recording-123.mp4")); + const id = await projectWith("Mine", video); + + const { owned, keptFor } = await findProjectRecordings( + id, + await documents.readAllProjectsReport(), + [recordingsDir], + ); + + expect(owned).toHaveLength(1); + expect(owned[0].files.sort()).toEqual([video, ...companions].sort()); + expect(owned[0].sizeBytes).toBe(5 + 3); + expect(keptFor).toEqual([]); + }); + + it("keeps a recording another project uses, and names that project", async () => { + const shared = await touch(path.join(recordingsDir, "recording-1.mp4")); + const own = await touch(path.join(recordingsDir, "recording-2.mp4")); + const id = await projectWith("Mine", shared, own); + await projectWith("Demo v2", shared); + + const { owned, keptFor } = await findProjectRecordings( + id, + await documents.readAllProjectsReport(), + [recordingsDir], + ); + + expect(owned.map((set) => set.stem)).toEqual(["recording-2"]); + expect(keptFor).toEqual(["Demo v2"]); + }); + + it("keeps every recording while a project file does not parse", async () => { + const video = await touch(path.join(recordingsDir, "recording-9.mp4")); + const id = await projectWith("Mine", video); + // Could be a project using recording-9: a newer schema, or a save cut short. + await touch(path.join(root, "projects", "broken.openscreen"), "{ not json"); + + const { owned, keptFor, unreadable } = await findProjectRecordings( + id, + await documents.readAllProjectsReport(), + [recordingsDir], + ); + + expect(owned).toEqual([]); + expect(keptFor).toEqual([]); + expect(unreadable).toBe(1); + }); + + it("reports no unreadable projects when it has no recordings to keep", async () => { + const imported = await touch(path.join(elsewhere, "clip.mp4")); + const id = await projectWith("Imported", imported); + await touch(path.join(root, "projects", "broken.openscreen"), "{ not json"); + + const { unreadable } = await findProjectRecordings( + id, + await documents.readAllProjectsReport(), + [recordingsDir], + ); + + expect(unreadable).toBe(0); + }); + + it("never owns a video outside the recordings folders", async () => { + const imported = await touch(path.join(elsewhere, "recording-5.mp4")); + const id = await projectWith("Imported", imported); + + const { owned } = await findProjectRecordings(id, await documents.readAllProjectsReport(), [ + recordingsDir, + ]); + + expect(owned).toEqual([]); + }); + + it("trashes each file, or removes it for good when asked", async () => { + const video = await touch(path.join(recordingsDir, "recording-7.mp4")); + const cursor = await touch(`${video}.cursor.json`); + const id = await projectWith("Mine", video); + const { owned } = await findProjectRecordings(id, await documents.readAllProjectsReport(), [ + recordingsDir, + ]); + + const trashItem = vi.fn(async (_filePath: string) => {}); + await removeRecordingSets(owned, "trash", trashItem); + expect(trashItem.mock.calls.map(([p]) => p).sort()).toEqual([video, cursor].sort()); + await expect(fs.access(video)).resolves.toBeUndefined(); + + await removeRecordingSets(owned, "permanent", trashItem); + await expect(fs.access(video)).rejects.toThrow(); + await expect(fs.access(cursor)).rejects.toThrow(); + }); +}); diff --git a/electron/ai-edition/project-recordings.ts b/electron/ai-edition/project-recordings.ts new file mode 100644 index 0000000000..122c7348af --- /dev/null +++ b/electron/ai-edition/project-recordings.ts @@ -0,0 +1,137 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AxcutDocument } from "../../src/lib/ai-edition/schema"; + +const RECORDING_STEM = /^recording-\d+/; + +/** One take: the screen video and every file recorded beside it (cursor, session, camera). */ +export interface RecordingSet { + dir: string; + stem: string; + files: string[]; + sizeBytes: number; +} + +export interface ProjectRecordings { + owned: RecordingSet[]; + /** Titles of the other projects that use some of this project's recordings. */ + keptFor: string[]; + /** + * Project files that did not parse. Any of them may use these recordings, so while there + * are some the project owns none: its recordings are all kept. + */ + unreadable: number; +} + +export type RecordingsDisposal = "keep" | "trash" | "permanent"; + +function documentMediaPaths(doc: AxcutDocument): string[] { + return doc.assets.flatMap((asset) => + [asset.originalPath, asset.proxyPath, asset.waveformPath, asset.cameraTrack?.sourcePath].filter( + (p): p is string => typeof p === "string" && p.length > 0, + ), + ); +} + +// `path.relative` compares case-insensitively on Windows, which a string compare would not. +function sameDir(a: string, b: string): boolean { + return path.relative(path.resolve(a), path.resolve(b)) === ""; +} + +function recordingKey(filePath: string, recordingsDirs: readonly string[]): string | null { + const dir = path.dirname(path.resolve(filePath)); + const managedDir = recordingsDirs.find((candidate) => sameDir(candidate, dir)); + if (!managedDir) return null; + const stem = RECORDING_STEM.exec(path.basename(filePath))?.[0]; + return stem ? path.join(path.resolve(managedDir), stem) : null; +} + +function documentRecordingKeys(doc: AxcutDocument, recordingsDirs: readonly string[]): Set { + const keys = new Set(); + for (const mediaPath of documentMediaPaths(doc)) { + const key = recordingKey(mediaPath, recordingsDirs); + if (key) keys.add(key); + } + return keys; +} + +async function readRecordingSet(key: string): Promise { + const dir = path.dirname(key); + const stem = path.basename(key); + let names: string[]; + try { + names = await fs.readdir(dir); + } catch { + return null; + } + const files: string[] = []; + let sizeBytes = 0; + for (const name of names) { + // `recording-12` must not pick up `recording-123`. + if (name !== stem && !name.startsWith(`${stem}.`) && !name.startsWith(`${stem}-`)) continue; + const filePath = path.join(dir, name); + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) continue; + files.push(filePath); + sizeBytes += stat.size; + } catch { + // Gone between the listing and the stat: nothing left to delete. + } + } + return files.length > 0 ? { dir, stem, files, sizeBytes } : null; +} + +/** + * The recordings `projectId` owns: takes in one of OpenScreen's recordings folders that no + * other project uses. A video imported from anywhere else is never the project's to delete. + */ +export async function findProjectRecordings( + projectId: string, + projects: { documents: readonly AxcutDocument[]; unreadable: readonly string[] }, + recordingsDirs: readonly string[], +): Promise { + const { documents } = projects; + const target = documents.find((doc) => doc.project.id === projectId); + if (!target) return { owned: [], keptFor: [], unreadable: 0 }; + + const keys = documentRecordingKeys(target, recordingsDirs); + if (keys.size > 0 && projects.unreadable.length > 0) { + return { owned: [], keptFor: [], unreadable: projects.unreadable.length }; + } + const keptFor = new Set(); + for (const doc of documents) { + if (doc.project.id === projectId) continue; + const otherKeys = documentRecordingKeys(doc, recordingsDirs); + for (const key of keys) { + if (otherKeys.has(key)) { + keys.delete(key); + keptFor.add(doc.project.title); + } + } + } + + const owned: RecordingSet[] = []; + for (const key of keys) { + const set = await readRecordingSet(key); + if (set) owned.push(set); + } + return { owned, keptFor: [...keptFor], unreadable: 0 }; +} + +/** Throws on the first file that cannot be removed, so a failed trash can be retried permanently. */ +export async function removeRecordingSets( + sets: readonly RecordingSet[], + disposal: Exclude, + trashItem: (filePath: string) => Promise, +): Promise { + for (const set of sets) { + for (const filePath of set.files) { + if (disposal === "trash") { + await trashItem(filePath); + } else { + await fs.rm(filePath, { force: true }); + } + } + } +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0fcd435b1b..45e26629f6 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5222,6 +5222,10 @@ export function registerIpcHandlers( } }, getAiEditionDocuments: () => aiEditionDocuments, + getRecordingsDirs: () => ({ + dirs: getAllowedReadDirs(), + registryDir: DEFAULT_RECORDINGS_DIR, + }), getStylePresets: () => stylePresets, getAiEditionLlmConfig, runAiEditionChat: (projectId, sessionId, message, document, sink, signal) => diff --git a/electron/ipc/nativeBridge.deleteProject.test.ts b/electron/ipc/nativeBridge.deleteProject.test.ts new file mode 100644 index 0000000000..abfe8be7b6 --- /dev/null +++ b/electron/ipc/nativeBridge.deleteProject.test.ts @@ -0,0 +1,62 @@ +// Deleting a project takes the recordings disposal from the renderer, and every value but +// "trash" would remove files for good, so the bridge accepts only the known ones. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NativeBridgeResponse } from "../../src/native/contracts"; +import { type NativeBridgeContext, registerNativeBridgeHandlers } from "./nativeBridge"; + +const electron = vi.hoisted(() => ({ handle: vi.fn() })); +vi.mock("electron", () => ({ + app: { getAppPath: () => "", isPackaged: false }, + ipcMain: { handle: electron.handle, removeHandler: vi.fn() }, + shell: {}, +})); +vi.mock("../native-bridge/services/compositorViewService", () => ({ + CompositorViewService: class {}, +})); + +const service = vi.hoisted(() => ({ + deleteProject: vi.fn(async () => ({ success: true })), +})); +vi.mock("../native-bridge/services/aiEditionService", () => ({ + AiEditionService: class { + deleteProject = service.deleteProject; + }, +})); + +let invoke: (payload: unknown) => Promise; + +beforeEach(() => { + electron.handle.mockClear(); + service.deleteProject.mockClear(); + registerNativeBridgeHandlers({ + getPlatform: () => "linux", + getAiEditionDocuments: () => ({}), + getAiEditionLlmConfig: () => ({}), + } as unknown as NativeBridgeContext); + const handler = electron.handle.mock.calls[0]?.[1] as ( + event: unknown, + request: unknown, + ) => Promise; + invoke = (payload) => + handler( + { sender: {} }, + { domain: "aiEdition", action: "document.delete", payload, requestId: "r" }, + ); +}); + +describe("native bridge project delete", () => { + it.each(["Trash", "delete", null, 1])("rejects recordings=%s as a bad request", async (value) => { + const response = await invoke({ projectId: "p", recordings: value }); + + expect(response.ok ? undefined : response.error?.code).toBe("INVALID_REQUEST"); + expect(service.deleteProject).not.toHaveBeenCalled(); + }); + + it.each([undefined, "keep", "trash", "permanent"])("passes recordings=%s on", async (value) => { + const response = await invoke({ projectId: "p", recordings: value }); + + expect(response.ok).toBe(true); + expect(service.deleteProject).toHaveBeenCalledWith("p", value); + }); +}); diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 602d2925be..2330a0b116 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -61,6 +61,8 @@ export interface NativeBridgeContext { */ getNativeWindowHandle?: (sender: import("electron").WebContents) => Buffer | null; getAiEditionDocuments: () => DocumentService; + /** OpenScreen's recordings folders, the current one first. Absent: deleting a project keeps its recordings. */ + getRecordingsDirs?: () => { dirs: string[]; registryDir: string }; /** The one shared style preset service — it serialises writes per instance. */ getStylePresets: () => StylePresetService; getAiEditionLlmConfig: () => import("../ai-edition/llm-config-store").LlmConfigStore; @@ -247,6 +249,13 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { // hit the macOS Keychain) while wiring the bridge at startup. llmConfig: context.getAiEditionLlmConfig, mcp: context.getMcpController?.(), + recordings: context.getRecordingsDirs + ? { + dirs: () => context.getRecordingsDirs?.().dirs ?? [], + registryDir: () => context.getRecordingsDirs?.().registryDir ?? "", + trashItem: (filePath) => shell.trashItem(filePath), + } + : undefined, runChat: context.runAiEditionChat, undoLastToolBatch: context.undoAiEditionToolBatch, rewindToMessage: context.rewindToMessage, @@ -564,10 +573,30 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await aiEditionService.save(request.payload.document), ); - case "document.delete": + // Renderer input: anything but "trash" would take the permanent path. + case "document.delete": { + const disposal: unknown = request.payload.recordings; + if ( + disposal !== undefined && + disposal !== "keep" && + disposal !== "trash" && + disposal !== "permanent" + ) { + return createErrorResponse( + requestId, + "INVALID_REQUEST", + "Invalid recordings disposal.", + ); + } + return createSuccessResponse( + requestId, + await aiEditionService.deleteProject(request.payload.projectId, disposal), + ); + } + case "document.recordings": return createSuccessResponse( requestId, - await aiEditionService.deleteProject(request.payload.projectId), + await aiEditionService.projectRecordings(request.payload.projectId), ); case "document.addAsset": return createSuccessResponse( diff --git a/electron/media/mediaLinksRegistry.test.ts b/electron/media/mediaLinksRegistry.test.ts index 7c76a75803..dd55709306 100644 --- a/electron/media/mediaLinksRegistry.test.ts +++ b/electron/media/mediaLinksRegistry.test.ts @@ -6,6 +6,7 @@ import { computeFingerprint, findMediaLinksByFingerprint, findRelocatedMediaByStoredPath, + forgetMediaLinks, registerMediaLinks, whenRegistryIdle, } from "./mediaLinksRegistry"; @@ -59,6 +60,22 @@ describe("mediaLinksRegistry", () => { await rmBestEffort(tempDir); }); + describe("forgetMediaLinks", () => { + it("drops only the entries of the deleted recordings", async () => { + const kept = path.join(tempDir, "recording-1.mp4"); + const deleted = path.join(tempDir, "recording-2.mp4"); + await writeFileOfSize(kept, 1024, "k"); + await writeFileOfSize(deleted, 2048, "d"); + await registerMediaLinks(tempDir, kept, { cursorTelemetryPath: `${kept}.cursor.json` }); + await registerMediaLinks(tempDir, deleted, { cursorTelemetryPath: `${deleted}.cursor.json` }); + + await forgetMediaLinks(tempDir, [deleted, `${deleted}.cursor.json`]); + + expect(await findMediaLinksByFingerprint(tempDir, kept)).not.toBeNull(); + expect(await findMediaLinksByFingerprint(tempDir, deleted)).toBeNull(); + }); + }); + describe("computeFingerprint", () => { it("never reads more than the head+tail sample regardless of file size", async () => { const bigPath = path.join(tempDir, "big.webm"); diff --git a/electron/media/mediaLinksRegistry.ts b/electron/media/mediaLinksRegistry.ts index 92f3c0b197..bcddc5c134 100644 --- a/electron/media/mediaLinksRegistry.ts +++ b/electron/media/mediaLinksRegistry.ts @@ -256,6 +256,20 @@ export async function registerMediaLinks( }); } +/** Drops the entries of deleted recordings, matched by the path they were last seen at. */ +export async function forgetMediaLinks( + baseDir: string, + deletedPaths: readonly string[], +): Promise { + if (deletedPaths.length === 0) return; + const deleted = deletedPaths.map((p) => path.resolve(p)); + const isDeleted = (p: string) => deleted.some((d) => path.relative(d, path.resolve(p)) === ""); + await updateRegistry(baseDir, (file) => ({ + version: 1, + entries: file.entries.filter((e) => !isDeleted(e.lastKnownPath)), + })); +} + export interface MediaLinksLookup { webcamVideoPath?: string; webcamOffsetMs?: number; diff --git a/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts b/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts new file mode 100644 index 0000000000..28acd00261 --- /dev/null +++ b/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts @@ -0,0 +1,110 @@ +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 { DocumentService } from "../../ai-edition/document-service"; +import { findMediaLinksByFingerprint, registerMediaLinks } from "../../media/mediaLinksRegistry"; +import { AiEditionService, type AiEditionServiceOptions } from "./aiEditionService"; + +describe("AiEditionService — deleting a project's recordings", () => { + let root: string; + let recordingsDir: string; + let documents: DocumentService; + let video: string; + let cursor: string; + let projectId: string; + let trashItem: ReturnType Promise>>; + + function service(): AiEditionService { + return new AiEditionService({ + documents, + llmConfig: () => { + throw new Error("not used"); + }, + recordings: { + dirs: () => [recordingsDir], + registryDir: () => recordingsDir, + trashItem, + }, + } as unknown as AiEditionServiceOptions); + } + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-delete-recordings-")); + recordingsDir = path.join(root, "recordings"); + await fs.mkdir(recordingsDir); + documents = new DocumentService(path.join(root, "projects"), root); + video = path.join(recordingsDir, "recording-42.mp4"); + cursor = `${video}.cursor.json`; + await fs.writeFile(video, "video"); + await fs.writeFile(cursor, "{}"); + await registerMediaLinks(recordingsDir, video, { cursorTelemetryPath: cursor }); + const doc = await documents.createProject("Take"); + await documents.addAsset(doc.project.id, { path: video, label: "recording-42.mp4" }); + projectId = doc.project.id; + trashItem = vi.fn(async (filePath: string) => { + await fs.rm(filePath); + }); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it("reports what the delete could remove", async () => { + await expect(service().projectRecordings(projectId)).resolves.toEqual({ + success: true, + count: 1, + sizeBytes: 5 + 2, + keptFor: [], + unreadable: 0, + }); + }); + + it("keeps the recordings unless asked", async () => { + await expect(service().deleteProject(projectId)).resolves.toEqual({ success: true }); + expect(trashItem).not.toHaveBeenCalled(); + await expect(fs.access(video)).resolves.toBeUndefined(); + }); + + it("moves them to the Trash, deletes the project and forgets their links", async () => { + await fs.writeFile(path.join(root, "probe.mp4"), "video"); + await expect(service().deleteProject(projectId, "trash")).resolves.toEqual({ success: true }); + + expect(trashItem).toHaveBeenCalledTimes(2); + await expect(documents.getProject(projectId)).rejects.toThrow(); + // Same bytes as the deleted take: only its registry entry could match. + expect( + await findMediaLinksByFingerprint(recordingsDir, path.join(root, "probe.mp4")), + ).toBeNull(); + }); + + it("deletes nothing when a project file stopped parsing after the dialog asked", async () => { + await fs.writeFile(path.join(root, "projects", "broken.openscreen"), "{ not json"); + + await expect(service().deleteProject(projectId, "trash")).resolves.toMatchObject({ + success: false, + }); + expect(trashItem).not.toHaveBeenCalled(); + await expect(fs.access(video)).resolves.toBeUndefined(); + await expect(documents.getProject(projectId)).resolves.toBeDefined(); + }); + + it("keeps everything when the Trash refuses, then deletes permanently on request", async () => { + trashItem.mockRejectedValueOnce(new Error("no trash on this filesystem")); + + await expect(service().deleteProject(projectId, "trash")).resolves.toMatchObject({ + success: false, + trashFailed: true, + }); + await expect(documents.getProject(projectId)).resolves.toBeDefined(); + await expect(fs.access(video)).resolves.toBeUndefined(); + + await expect(service().deleteProject(projectId, "permanent")).resolves.toEqual({ + success: true, + }); + await expect(fs.access(video)).rejects.toThrow(); + await expect(fs.access(cursor)).rejects.toThrow(); + await expect(documents.getProject(projectId)).rejects.toThrow(); + }); +}); diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 553a049ff2..7692df620f 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -9,12 +9,15 @@ import type { AiEditionChatRewindResult, AiEditionChatSession, AiEditionChatSessionSummary, + AiEditionDeleteResult, AiEditionDocumentResult, AiEditionLlmConfig, AiEditionLlmDisconnectResult, AiEditionLlmSnapshot, AiEditionMcpStatus, + AiEditionProjectRecordingsResult, AiEditionProjectSummary, + AiEditionRecordingsDisposal, } from "../../../src/native/contracts"; import { type CaptionTranslateSegment, @@ -31,8 +34,10 @@ import { listOpenRouterModels, probeMiniMaxModels, } from "../../ai-edition/llm-provider-auth"; +import { findProjectRecordings, removeRecordingSets } from "../../ai-edition/project-recordings"; import { PROVIDER_DEFINITIONS } from "../../ai-edition/provider-registry"; import type { McpController } from "../../mcp/mcp-controller"; +import { forgetMediaLinks } from "../../media/mediaLinksRegistry"; export interface AiEditionServiceOptions { documents: DocumentService; @@ -86,6 +91,13 @@ export interface AiEditionServiceOptions { deleteSession: (projectId: string, sessionId: string) => boolean; /** The local MCP server. Absent where no server is wired (tests, headless CLI). */ mcp?: McpController; + /** OpenScreen's recordings folders. Absent: a project's recordings are always kept. */ + recordings?: { + dirs: () => string[]; + /** Where `media-links.registry.json` lives. */ + registryDir: () => string; + trashItem: (filePath: string) => Promise; + }; } export class AiEditionService { @@ -145,7 +157,69 @@ export class AiEditionService { } } - async deleteProject(projectId: string): Promise { + async projectRecordings(projectId: string): Promise { + const recordings = this.options.recordings; + if (!recordings) return { success: true, count: 0, sizeBytes: 0, keptFor: [], unreadable: 0 }; + try { + const { owned, keptFor, unreadable } = await findProjectRecordings( + projectId, + await this.options.documents.readAllProjectsReport(), + recordings.dirs(), + ); + return { + success: true, + count: owned.length, + sizeBytes: owned.reduce((sum, set) => sum + set.sizeBytes, 0), + keptFor, + unreadable, + }; + } catch (error) { + return { + success: false, + count: 0, + sizeBytes: 0, + keptFor: [], + unreadable: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async deleteProject( + projectId: string, + disposal: AiEditionRecordingsDisposal = "keep", + ): Promise { + const recordings = this.options.recordings; + // The recordings go first: a project whose files could not be removed is kept, so + // the user can retry, instead of leaving takes that nothing points at any more. + if (disposal !== "keep" && recordings) { + try { + const { owned, unreadable } = await findProjectRecordings( + projectId, + await this.options.documents.readAllProjectsReport(), + recordings.dirs(), + ); + // A project file broke since the dialog asked: deleting the project alone would + // quietly drop the recordings the user chose to remove, so delete nothing. + if (unreadable > 0) { + return { + success: false, + error: `${unreadable} project file(s) could not be read, so recordings they may use were not deleted`, + }; + } + await removeRecordingSets(owned, disposal, recordings.trashItem); + await forgetMediaLinks( + recordings.registryDir(), + owned.flatMap((set) => set.files), + ).catch((error) => console.warn("[ai-edition] could not update media links:", error)); + } catch (error) { + return { + success: false, + trashFailed: disposal === "trash", + error: error instanceof Error ? error.message : String(error), + }; + } + } try { await this.options.documents.deleteProject(projectId); } catch (error) { diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index abe259c12f..41cd79d981 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -11,9 +11,15 @@ import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { formatSeconds } from "@/lib/ai-edition/timeline/format"; +import type { + AiEditionProjectRecordingsResult, + AiEditionRecordingsDisposal, +} from "@/native/contracts"; +import { formatBytes } from "@/utils/formatBytes"; +import { getPlatform } from "@/utils/platformUtils"; import { cropDraftFromRegion, cropDraftToPct, previewBoxStyle } from "./cropDraft"; import styles from "./NewEditorShell.module.css"; -import { ChoiceRow } from "./RightPanes"; +import { ChoiceRow, Toggle } from "./RightPanes"; import type { VideoSource } from "./VirtualPreview"; interface BaseModalProps { @@ -124,7 +130,12 @@ interface OpenProjectModalProps extends BaseModalProps { projects: ProjectItem[]; activeProjectId: string | null; onSelect: (id: string) => void; - onDelete: (id: string) => void; + onDelete: ( + id: string, + recordings: AiEditionRecordingsDisposal, + ) => Promise<{ trashFailed?: boolean } | undefined>; + /** The recordings deleting `id` could also remove. */ + loadRecordings: (id: string) => Promise; onBrowse: () => void; } @@ -135,6 +146,7 @@ export function OpenProjectModal({ activeProjectId, onSelect, onDelete, + loadRecordings, onBrowse, }: OpenProjectModalProps) { const t = useScopedT("editor"); @@ -144,9 +156,48 @@ export function OpenProjectModal({ // confirm on its own row instead of deleting on the first click. One row at a // time, and closing the dialog disarms it. const [confirmId, setConfirmId] = useState(null); + const [recordings, setRecordings] = useState(null); + const [trashRecordings, setTrashRecordings] = useState(false); + const [trashFailed, setTrashFailed] = useState(false); + const [deleting, setDeleting] = useState(false); + const armedIdRef = useRef(null); + const arm = (id: string | null) => { + armedIdRef.current = id; + setConfirmId(id); + setRecordings(null); + setTrashRecordings(false); + setTrashFailed(false); + if (!id) return; + void loadRecordings(id) + .then((result) => { + if (armedIdRef.current === id && result.success) setRecordings(result); + }) + // Without the answer the row offers no toggle, and the delete keeps the recordings. + .catch(() => undefined); + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: disarm only when the dialog closes useEffect(() => { - if (!open) setConfirmId(null); + if (!open) arm(null); }, [open]); + const recycleBin = getPlatform() === "win32"; + const confirmDelete = async (id: string) => { + const disposal: AiEditionRecordingsDisposal = trashFailed + ? "permanent" + : trashRecordings && recordings && recordings.count > 0 + ? "trash" + : "keep"; + setDeleting(true); + try { + const result = await onDelete(id, disposal); + if (result?.trashFailed) { + setTrashFailed(true); + return; + } + arm(null); + } finally { + setDeleting(false); + } + }; const filtered = projects.filter((p) => p.title.toLowerCase().includes(query.toLowerCase())); // The row ↑↓ moved to, or null for the default: the open project, or the first match once a // search narrows the list. Enter opens it, as the footer says (#1135). @@ -261,26 +312,86 @@ export function OpenProjectModal({ marginTop: 2, }} > - {t("openProjectDialog.confirmDelete")} + {trashFailed + ? t( + recycleBin + ? "openProjectDialog.recycleFailed" + : "openProjectDialog.trashFailed", + ) + : recordings && recordings.count > 0 + ? t("openProjectDialog.confirmDeleteProject") + : t("openProjectDialog.confirmDelete")} + {!trashFailed && recordings && recordings.count > 0 ? ( +
+ + + {t( + recycleBin + ? "openProjectDialog.alsoRecycle" + : "openProjectDialog.alsoTrash", + { size: formatBytes(recordings.sizeBytes) }, + )} + +
+ ) : null} + {!trashFailed && recordings && recordings.keptFor.length > 0 ? ( +
+ {t("openProjectDialog.recordingsKeptFor", { + projects: recordings.keptFor.join(", "), + })} +
+ ) : null} + {!trashFailed && recordings && recordings.unreadable > 0 ? ( +
+ {t("openProjectDialog.recordingsKeptUnreadable")} +
+ ) : null} ); @@ -360,7 +471,7 @@ export function OpenProjectModal({