From 63ade1628fe267f51ce25606ceeff4c3128f6f1a Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Wed, 14 Oct 2026 15:28:27 -0400 Subject: [PATCH 1/3] feat(delete): add delete recordings option --- electron/ai-edition/document-service.ts | 2 +- .../ai-edition/project-recordings.test.ts | 105 ++++++++++++++ electron/ai-edition/project-recordings.ts | 128 ++++++++++++++++++ electron/ipc/handlers.ts | 4 + electron/ipc/nativeBridge.ts | 19 ++- electron/media/mediaLinksRegistry.test.ts | 17 +++ electron/media/mediaLinksRegistry.ts | 14 ++ .../aiEditionService.deleteRecordings.test.ts | 98 ++++++++++++++ .../services/aiEditionService.ts | 66 ++++++++- src/components/ai-edition/Modals.tsx | 122 +++++++++++++++-- src/components/ai-edition/NewEditorShell.tsx | 45 +++--- .../ai-edition/OpenProjectModal.test.tsx | 89 +++++++++++- src/i18n/locales/ar/editor.json | 9 +- src/i18n/locales/cs/editor.json | 9 +- src/i18n/locales/de/editor.json | 9 +- src/i18n/locales/en/editor.json | 9 +- src/i18n/locales/es/editor.json | 9 +- src/i18n/locales/fr/editor.json | 9 +- src/i18n/locales/it/editor.json | 9 +- src/i18n/locales/ja-JP/editor.json | 9 +- src/i18n/locales/ko-KR/editor.json | 9 +- src/i18n/locales/pt-BR/editor.json | 9 +- src/i18n/locales/ru/editor.json | 9 +- src/i18n/locales/tr/editor.json | 9 +- src/i18n/locales/vi/editor.json | 9 +- src/i18n/locales/zh-CN/editor.json | 9 +- src/i18n/locales/zh-TW/editor.json | 9 +- src/native/browserShim.ts | 2 + src/native/client.ts | 13 +- src/native/contracts.ts | 23 ++++ .../testing/manual-e2e-checklist.md | 8 ++ website/docs/media-library.md | 2 + .../current/media-library.md | 2 + .../current/media-library.md | 2 + .../current/media-library.md | 2 + .../current/media-library.md | 2 + .../current/media-library.md | 2 + .../current/media-library.md | 2 + .../current/media-library.md | 2 + 39 files changed, 850 insertions(+), 56 deletions(-) create mode 100644 electron/ai-edition/project-recordings.test.ts create mode 100644 electron/ai-edition/project-recordings.ts create mode 100644 electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 52e1bb97e6..dfabb1ef72 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -385,7 +385,7 @@ export class DocumentService { } /** Every project that parses, unrelinked and unannounced: a listing, not an open. */ - private async readAllProjects(): Promise { + async readAllProjects(): Promise { await this.ensureProjectsDir(); const entries = await fs.readdir(this.projectsRoot); // ensureProjectsDir (above) already migrated any legacy `.axcut` files. diff --git a/electron/ai-edition/project-recordings.test.ts b/electron/ai-edition/project-recordings.test.ts new file mode 100644 index 0000000000..3ec7a884c9 --- /dev/null +++ b/electron/ai-edition/project-recordings.test.ts @@ -0,0 +1,105 @@ +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.readAllProjects(), [ + 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.readAllProjects(), [ + recordingsDir, + ]); + + expect(owned.map((set) => set.stem)).toEqual(["recording-2"]); + expect(keptFor).toEqual(["Demo v2"]); + }); + + 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.readAllProjects(), [ + 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.readAllProjects(), [ + 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..6fcedb1861 --- /dev/null +++ b/electron/ai-edition/project-recordings.ts @@ -0,0 +1,128 @@ +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[]; +} + +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, + documents: readonly AxcutDocument[], + recordingsDirs: readonly string[], +): Promise { + const target = documents.find((doc) => doc.project.id === projectId); + if (!target) return { owned: [], keptFor: [] }; + + const keys = documentRecordingKeys(target, recordingsDirs); + 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] }; +} + +/** 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.ts b/electron/ipc/nativeBridge.ts index 602d2925be..fb1a087fb6 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, @@ -567,7 +576,15 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { case "document.delete": return createSuccessResponse( requestId, - await aiEditionService.deleteProject(request.payload.projectId), + await aiEditionService.deleteProject( + request.payload.projectId, + request.payload.recordings, + ), + ); + case "document.recordings": + return createSuccessResponse( + requestId, + 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..44110bbf9d --- /dev/null +++ b/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts @@ -0,0 +1,98 @@ +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: [], + }); + }); + + 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("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..20e2f6b69d 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,59 @@ 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: [] }; + try { + const { owned, keptFor } = await findProjectRecordings( + projectId, + await this.options.documents.readAllProjects(), + recordings.dirs(), + ); + return { + success: true, + count: owned.length, + sizeBytes: owned.reduce((sum, set) => sum + set.sizeBytes, 0), + keptFor, + }; + } catch (error) { + return { + success: false, + count: 0, + sizeBytes: 0, + keptFor: [], + 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 } = await findProjectRecordings( + projectId, + await this.options.documents.readAllProjects(), + recordings.dirs(), + ); + 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..10846c1925 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,75 @@ 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} ); @@ -360,7 +460,7 @@ export function OpenProjectModal({