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..d7259a62ce --- /dev/null +++ b/electron/ai-edition/project-recordings.test.ts @@ -0,0 +1,224 @@ +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, + listRecordings, + removeRecordingSets, + resolveRecordingIds, +} 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(); + }); + + describe("listing every take", () => { + async function at(filePath: string, isoDate: string): Promise { + await touch(filePath); + const when = new Date(isoDate); + await fs.utimes(filePath, when, when); + return filePath; + } + + it("lists each take once with its files, newest first, and who uses it", async () => { + const older = await at(path.join(recordingsDir, "recording-1.mp4"), "2026-01-01"); + await at(`${older}.cursor.json`, "2026-01-01"); + const newer = await at(path.join(recordingsDir, "recording-2.mp4"), "2026-02-01"); + await touch(path.join(recordingsDir, "media-links.registry.json")); + await touch(path.join(recordingsDir, "notes.txt")); + await projectWith("Demo", older); + await projectWith("Demo v2", older); + + const { takes, unreadable } = await listRecordings( + await documents.readAllProjectsReport(), + // The same folder twice, as when the current folder is the default one. + [recordingsDir, `${recordingsDir}${path.sep}`], + ); + + expect(takes.map((take) => take.stem)).toEqual(["recording-2", "recording-1"]); + expect(takes[0].usedBy).toEqual([]); + expect(takes[0].files).toEqual([newer]); + expect(takes[1].usedBy.sort()).toEqual(["Demo", "Demo v2"]); + expect(takes[1].files).toHaveLength(2); + expect(takes[1].id).toBe(path.join(recordingsDir, "recording-1")); + expect(unreadable).toBe(0); + }); + + it("counts project files that do not parse", async () => { + await touch(path.join(recordingsDir, "recording-3.mp4")); + await fs.mkdir(path.join(root, "projects"), { recursive: true }); + await touch(path.join(root, "projects", "broken.openscreen"), "{ not json"); + + const { takes, unreadable } = await listRecordings(await documents.readAllProjectsReport(), [ + recordingsDir, + ]); + + expect(takes).toHaveLength(1); + expect(unreadable).toBe(1); + }); + + it("skips a recordings folder that is gone", async () => { + const { takes } = await listRecordings(await documents.readAllProjectsReport(), [ + path.join(root, "unplugged"), + ]); + + expect(takes).toEqual([]); + }); + }); + + describe("resolving the ids a delete names", () => { + it("resolves a take in a recordings folder to its files", async () => { + const video = await touch(path.join(recordingsDir, "recording-4.mp4")); + + const sets = await resolveRecordingIds( + [path.join(recordingsDir, "recording-4")], + [recordingsDir], + ); + + expect(sets.map((set) => set.files)).toEqual([[video]]); + }); + + it.each([ + ["a take outside the recordings folders", () => path.join(elsewhere, "recording-4")], + ["a file name instead of a take", () => path.join(recordingsDir, "recording-4.mp4")], + ["something that is not a take", () => path.join(recordingsDir, "media-links")], + ["a path that climbs out", () => path.join(recordingsDir, "..", "recording-4")], + ])("refuses %s", async (_label, id) => { + await touch(path.join(elsewhere, "recording-4.mp4")); + await touch(path.join(root, "recording-4.mp4")); + + await expect(resolveRecordingIds([id()], [recordingsDir])).rejects.toThrow(/Not a recording/); + }); + }); +}); diff --git a/electron/ai-edition/project-recordings.ts b/electron/ai-edition/project-recordings.ts new file mode 100644 index 0000000000..ddf16cfe5e --- /dev/null +++ b/electron/ai-edition/project-recordings.ts @@ -0,0 +1,223 @@ +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; + /** The oldest modification time among its files, in ms. */ + recordedAt: 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); + let names: string[]; + try { + names = await fs.readdir(dir); + } catch { + return null; + } + return collectRecordingSet(dir, path.basename(key), names); +} + +async function collectRecordingSet( + dir: string, + stem: string, + names: readonly string[], +): Promise { + const files: string[] = []; + let sizeBytes = 0; + let recordedAt = Number.POSITIVE_INFINITY; + 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; + recordedAt = Math.min(recordedAt, stat.mtimeMs); + } catch { + // Gone between the listing and the stat: nothing left to delete. + } + } + return files.length > 0 ? { dir, stem, files, sizeBytes, recordedAt } : 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 }); + } + } + } +} + +/** A take in the recordings folders, and the projects that use it. */ +export interface RecordingTake extends RecordingSet { + /** `dir` joined with `stem`: what a delete names the take by. */ + id: string; + usedBy: string[]; +} + +export interface RecordingsListing { + takes: RecordingTake[]; + /** Project files that did not parse: any take may be in use by one of them. */ + unreadable: number; +} + +function uniqueDirs(recordingsDirs: readonly string[]): string[] { + const dirs: string[] = []; + for (const dir of recordingsDirs) { + if (!dirs.some((seen) => sameDir(seen, dir))) dirs.push(path.resolve(dir)); + } + return dirs; +} + +/** Every take in OpenScreen's recordings folders, newest first. */ +export async function listRecordings( + projects: { documents: readonly AxcutDocument[]; unreadable: readonly string[] }, + recordingsDirs: readonly string[], +): Promise { + const usedBy = new Map(); + for (const doc of projects.documents) { + for (const key of documentRecordingKeys(doc, recordingsDirs)) { + usedBy.set(key, [...(usedBy.get(key) ?? []), doc.project.title]); + } + } + + const takes: RecordingTake[] = []; + for (const dir of uniqueDirs(recordingsDirs)) { + let names: string[]; + try { + names = await fs.readdir(dir); + } catch { + // A folder that is gone (an unplugged drive) has nothing to list. + continue; + } + const stems = new Set( + names.map((name) => RECORDING_STEM.exec(name)?.[0]).filter((s): s is string => !!s), + ); + for (const stem of stems) { + const set = await collectRecordingSet(dir, stem, names); + if (!set) continue; + const id = path.join(dir, stem); + takes.push({ ...set, id, usedBy: usedBy.get(id) ?? [] }); + } + } + takes.sort((a, b) => b.recordedAt - a.recordedAt); + return { takes, unreadable: projects.unreadable.length }; +} + +/** + * The takes `ids` name, each checked to be a take in a recordings folder: the ids come from + * the renderer, and a delete must never reach a file outside those folders. + */ +export async function resolveRecordingIds( + ids: readonly string[], + recordingsDirs: readonly string[], +): Promise { + const sets: RecordingSet[] = []; + for (const id of ids) { + const stem = path.basename(id); + if (RECORDING_STEM.exec(stem)?.[0] !== stem) throw new Error(`Not a recording: ${id}`); + if (recordingKey(id, recordingsDirs) === null) throw new Error(`Not a recording: ${id}`); + const set = await readRecordingSet(path.resolve(id)); + if (set) sets.push(set); + } + return sets; +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8ac4658c79..cee4dcd5ae 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) => diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 46e75ae58a..85cc831027 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -60,6 +60,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; @@ -239,6 +241,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, @@ -558,7 +567,25 @@ 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 "recordings.list": + return createSuccessResponse(requestId, await aiEditionService.listRecordings()); + case "recordings.delete": + return createSuccessResponse( + requestId, + await aiEditionService.deleteRecordings( + request.payload.ids, + request.payload.disposal, + ), ); 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..7fc4af9d54 --- /dev/null +++ b/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts @@ -0,0 +1,172 @@ +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(); + }); + + describe("managing every recording", () => { + it("lists the take with the project that uses it", async () => { + const result = await service().listRecordings(); + + expect(result).toMatchObject({ success: true, unreadable: 0 }); + expect(result.recordings).toHaveLength(1); + expect(result.recordings[0]).toMatchObject({ + id: path.join(recordingsDir, "recording-42"), + usedBy: ["Take"], + sizeBytes: 5 + 2, + }); + }); + + it("trashes the named takes and forgets their links", async () => { + const other = path.join(recordingsDir, "recording-43.mp4"); + await fs.writeFile(other, "other"); + await fs.writeFile(path.join(root, "probe.mp4"), "video"); + + await expect( + service().deleteRecordings([path.join(recordingsDir, "recording-42")], "trash"), + ).resolves.toEqual({ success: true }); + + expect(trashItem).toHaveBeenCalledTimes(2); + await expect(fs.access(video)).rejects.toThrow(); + await expect(fs.access(other)).resolves.toBeUndefined(); + expect( + await findMediaLinksByFingerprint(recordingsDir, path.join(root, "probe.mp4")), + ).toBeNull(); + }); + + it("refuses an id outside the recordings folders and deletes nothing", async () => { + const outside = path.join(root, "recording-42.mp4"); + await fs.writeFile(outside, "video"); + + await expect( + service().deleteRecordings([path.join(root, "recording-42")], "permanent"), + ).resolves.toMatchObject({ success: false, trashFailed: false }); + await expect(fs.access(outside)).resolves.toBeUndefined(); + }); + + it("keeps the links of files the Trash refused, then deletes them permanently", async () => { + await fs.writeFile(path.join(root, "probe.mp4"), "video"); + trashItem.mockRejectedValueOnce(new Error("no trash on this filesystem")); + const id = path.join(recordingsDir, "recording-42"); + + await expect(service().deleteRecordings([id], "trash")).resolves.toMatchObject({ + success: false, + trashFailed: true, + }); + await expect(fs.access(video)).resolves.toBeUndefined(); + expect( + await findMediaLinksByFingerprint(recordingsDir, path.join(root, "probe.mp4")), + ).not.toBeNull(); + + await expect(service().deleteRecordings([id], "permanent")).resolves.toEqual({ + success: true, + }); + await expect(fs.access(video)).rejects.toThrow(); + await expect(fs.access(cursor)).rejects.toThrow(); + }); + }); +}); diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 01e880a3ee..3cfeca7447 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { documentSchema } from "../../../src/lib/ai-edition/schema"; import type { AiEditionAssetResult, @@ -9,12 +10,17 @@ import type { AiEditionChatRewindResult, AiEditionChatSession, AiEditionChatSessionSummary, + AiEditionDeleteResult, AiEditionDocumentResult, AiEditionLlmConfig, AiEditionLlmDisconnectResult, AiEditionLlmSnapshot, AiEditionMcpStatus, + AiEditionProjectRecordingsResult, AiEditionProjectSummary, + AiEditionRecordingsDeleteResult, + AiEditionRecordingsDisposal, + AiEditionRecordingsListResult, } from "../../../src/native/contracts"; import { type CaptionTranslateSegment, @@ -31,8 +37,22 @@ import { listOpenRouterModels, probeMiniMaxModels, } from "../../ai-edition/llm-provider-auth"; +import { + findProjectRecordings, + listRecordings, + removeRecordingSets, + resolveRecordingIds, +} 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"; + +async function fileExists(filePath: string): Promise { + return fs.access(filePath).then( + () => true, + () => false, + ); +} export interface AiEditionServiceOptions { documents: DocumentService; @@ -85,6 +105,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 { @@ -144,7 +171,118 @@ 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 listRecordings(): Promise { + const recordings = this.options.recordings; + if (!recordings) return { success: true, recordings: [], unreadable: 0 }; + try { + const { takes, unreadable } = await listRecordings( + await this.options.documents.readAllProjectsReport(), + recordings.dirs(), + ); + return { success: true, recordings: takes, unreadable }; + } catch (error) { + return { + success: false, + recordings: [], + unreadable: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** Deletes takes whether or not a project uses them: the dialog asked the user first. */ + async deleteRecordings( + ids: string[], + disposal: Exclude, + ): Promise { + const recordings = this.options.recordings; + if (!recordings) return { success: false, error: "No recordings folder" }; + let sets: Awaited> = []; + try { + sets = await resolveRecordingIds(ids, recordings.dirs()); + await removeRecordingSets(sets, disposal, recordings.trashItem); + } catch (error) { + return { + success: false, + trashFailed: disposal === "trash" && sets.length > 0, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + // Forget only what is gone: a failed delete leaves files whose links still matter. + const gone: string[] = []; + for (const file of sets.flatMap((set) => set.files)) { + if (!(await fileExists(file))) gone.push(file); + } + await forgetMediaLinks(recordings.registryDir(), gone).catch((error) => + console.warn("[ai-edition] could not update media links:", error), + ); + } + return { success: true }; + } + + 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/ManageRecordingsModal.test.tsx b/src/components/ai-edition/ManageRecordingsModal.test.tsx new file mode 100644 index 0000000000..bab4527629 --- /dev/null +++ b/src/components/ai-edition/ManageRecordingsModal.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { + AiEditionRecording, + AiEditionRecordingsDeleteResult, + AiEditionRecordingsListResult, +} from "@/native/contracts"; +import { ManageRecordingsModal } from "./ManageRecordingsModal"; + +// The Trash is called the Recycle Bin on Windows: pin the platform rather than inherit the host's. +const platform = vi.hoisted(() => ({ current: "darwin" as NodeJS.Platform })); +vi.mock("@/utils/platformUtils", () => ({ getPlatform: () => platform.current })); + +const MB = 1024 * 1024; + +function recording(stem: string, usedBy: string[], sizeBytes: number): AiEditionRecording { + return { + id: `/recordings/${stem}`, + stem, + files: [`/recordings/${stem}.mp4`, `/recordings/${stem}.mp4.cursor.json`], + sizeBytes, + recordedAt: Date.UTC(2026, 9, 1), + usedBy, + }; +} + +const used = recording("recording-1", ["Demo"], 10 * MB); +const unused = recording("recording-2", [], 20 * MB); + +function renderModal( + overrides: { + listing?: AiEditionRecordingsListResult; + deleteRecordings?: (...args: unknown[]) => Promise; + } = {}, +) { + const listing = overrides.listing ?? { + success: true, + recordings: [used, unused], + unreadable: 0, + }; + const loadRecordings = vi.fn(async () => listing); + const deleteRecordings = vi.fn(overrides.deleteRecordings ?? (async () => ({ success: true }))); + const onReveal = vi.fn(); + render( + + + , + ); + return { loadRecordings, deleteRecordings, onReveal }; +} + +describe("ManageRecordingsModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + platform.current = "darwin"; + }); + + it("lists each recording with its size and who uses it", async () => { + renderModal(); + + expect(await screen.findByText("Used by Demo")).toBeInTheDocument(); + expect(screen.getByText("Not used by any project")).toBeInTheDocument(); + expect(screen.getByText("20 MB")).toBeInTheDocument(); + expect(screen.getByText("30 MB in total")).toBeInTheDocument(); + }); + + it("selects only the unused ones, and trashes them without a warning", async () => { + const { deleteRecordings } = renderModal(); + + fireEvent.click(await screen.findByRole("button", { name: "Select unused" })); + expect(screen.getByText("1 selected · 20 MB")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect(screen.getByText(/selected recordings \(20 MB\) to the Trash/)).toBeInTheDocument(); + expect(screen.queryByText(/will lose that media/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Move to Trash" })); + + await waitFor(() => + expect(deleteRecordings).toHaveBeenCalledWith(["/recordings/recording-2"], "trash"), + ); + }); + + it("names the projects that lose media when a used recording is selected", async () => { + renderModal(); + + fireEvent.click(await screen.findByRole("checkbox", { name: /Used by Demo/ })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect( + screen.getByText("Some are used by Demo, which will lose that media."), + ).toBeInTheDocument(); + }); + + it("asks to delete permanently when the Trash refuses", async () => { + const { deleteRecordings } = renderModal({ + deleteRecordings: async (_ids, disposal) => + disposal === "trash" ? { success: false, trashFailed: true } : { success: true }, + }); + + fireEvent.click(await screen.findByRole("button", { name: "Select all" })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + fireEvent.click(screen.getByRole("button", { name: "Move to Trash" })); + + const permanent = await screen.findByRole("button", { name: /delete permanently/i }); + fireEvent.click(permanent); + + await waitFor(() => + expect(deleteRecordings).toHaveBeenLastCalledWith( + ["/recordings/recording-1", "/recordings/recording-2"], + "permanent", + ), + ); + }); + + it("says Recycle Bin on Windows", async () => { + platform.current = "win32"; + renderModal(); + + fireEvent.click(await screen.findByRole("button", { name: "Select unused" })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect(screen.getByRole("button", { name: "Move to Recycle Bin" })).toBeInTheDocument(); + }); + + it("warns that usage is uncertain while a project file does not parse", async () => { + renderModal({ listing: { success: true, recordings: [unused], unreadable: 1 } }); + + expect(await screen.findByText(/marked unused may still be in use/)).toBeInTheDocument(); + }); + + it("shows the screen video in its folder", async () => { + const { onReveal } = renderModal(); + + const reveal = await screen.findAllByRole("button", { name: "Show in folder" }); + fireEvent.click(reveal[1]); + + expect(onReveal).toHaveBeenCalledWith("/recordings/recording-2.mp4"); + }); +}); diff --git a/src/components/ai-edition/ManageRecordingsModal.tsx b/src/components/ai-edition/ManageRecordingsModal.tsx new file mode 100644 index 0000000000..dbc5951645 --- /dev/null +++ b/src/components/ai-edition/ManageRecordingsModal.tsx @@ -0,0 +1,331 @@ +import { FolderSearch, Loader2, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { + AiEditionRecording, + AiEditionRecordingsDeleteResult, + AiEditionRecordingsListResult, +} from "@/native/contracts"; +import { formatBytes } from "@/utils/formatBytes"; +import { getPlatform } from "@/utils/platformUtils"; +import { ModalShell } from "./Modals"; +import styles from "./NewEditorShell.module.css"; + +interface ManageRecordingsModalProps { + open: boolean; + onClose: () => void; + loadRecordings: () => Promise; + deleteRecordings: ( + ids: string[], + disposal: "trash" | "permanent", + ) => Promise; + onReveal?: (filePath: string) => void; +} + +const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov"]; + +/** The screen video, which is what "Show in folder" points at. */ +function screenVideo(recording: AiEditionRecording): string { + return ( + recording.files.find((file) => + VIDEO_EXTENSIONS.some((ext) => file.endsWith(`${recording.stem}${ext}`)), + ) ?? recording.files[0] + ); +} + +const mutedLine = { + font: "400 12px/1.4 var(--font-body)", + color: "var(--muted)", +} as const; + +export function ManageRecordingsModal({ + open, + onClose, + loadRecordings, + deleteRecordings, + onReveal, +}: ManageRecordingsModalProps) { + const t = useScopedT("editor"); + const tc = useScopedT("common"); + const [listing, setListing] = useState(null); + const [selected, setSelected] = useState>(() => new Set()); + const [confirming, setConfirming] = useState(false); + const [trashFailed, setTrashFailed] = useState(false); + const [deleting, setDeleting] = useState(false); + const recycleBin = getPlatform() === "win32"; + + const reload = useCallback(async () => { + let result: AiEditionRecordingsListResult; + try { + result = await loadRecordings(); + } catch (error) { + result = { success: false, recordings: [], unreadable: 0, error: String(error) }; + } + setListing(result); + // A take deleted elsewhere drops out of the selection with the list. + const ids = new Set(result.recordings.map((r) => r.id)); + setSelected((prev) => new Set([...prev].filter((id) => ids.has(id)))); + }, [loadRecordings]); + + useEffect(() => { + if (!open) return; + setListing(null); + setSelected(new Set()); + setConfirming(false); + setTrashFailed(false); + void reload(); + }, [open, reload]); + + const recordings = listing?.recordings ?? []; + const chosen = recordings.filter((r) => selected.has(r.id)); + const chosenSize = chosen.reduce((sum, r) => sum + r.sizeBytes, 0); + const totalSize = recordings.reduce((sum, r) => sum + r.sizeBytes, 0); + const inUseBy = [...new Set(chosen.flatMap((r) => r.usedBy))]; + const unreadable = listing?.unreadable ?? 0; + + const toggle = (id: string) => { + setConfirming(false); + setTrashFailed(false); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const select = (ids: string[]) => { + setConfirming(false); + setTrashFailed(false); + setSelected(new Set(ids)); + }; + + const runDelete = async () => { + setDeleting(true); + try { + const result = await deleteRecordings( + chosen.map((r) => r.id), + trashFailed ? "permanent" : "trash", + ); + if (!result.success && result.trashFailed) { + setTrashFailed(true); + } else { + setConfirming(false); + setTrashFailed(false); + } + await reload(); + } finally { + setDeleting(false); + } + }; + + const footer = confirming ? ( +
+
+
+ {trashFailed + ? t(recycleBin ? "openProjectDialog.recycleFailed" : "openProjectDialog.trashFailed") + : t(recycleBin ? "recordingsDialog.confirmRecycle" : "recordingsDialog.confirmTrash", { + size: formatBytes(chosenSize), + })} +
+ {inUseBy.length > 0 ? ( +
+ {t("recordingsDialog.inUseWarning", { projects: inUseBy.join(", ") })} +
+ ) : null} + {unreadable > 0 ? ( +
{t("recordingsDialog.unreadable")}
+ ) : null} +
+ + +
+ ) : ( +
+ + {chosen.length > 0 + ? t("recordingsDialog.selected", { + count: chosen.length, + size: formatBytes(chosenSize), + }) + : t("recordingsDialog.total", { size: formatBytes(totalSize) })} + + + + +
+ ); + + return ( + + {unreadable > 0 ? ( +

+ {t("recordingsDialog.unreadable")} +

+ ) : null} + {listing === null ? ( +

+ + {t("recordingsDialog.loading")} +

+ ) : !listing.success ? ( +

+ {t("recordingsDialog.loadFailed")} +

+ ) : recordings.length === 0 ? ( +

+ {t("recordingsDialog.empty")} +

+ ) : ( +
    + {recordings.map((recording) => { + const label = new Date(recording.recordedAt).toLocaleString(); + const checked = selected.has(recording.id); + return ( +
  • + + {onReveal ? ( + + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} 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({ +