From e03c0c8edaf8836215348c530cab83f6b03fc20b Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Sat, 24 Oct 2026 10:52:14 -0400 Subject: [PATCH 1/2] feat(recordings): list every recording and delete several at once Manage recordings, in the OpenScreen menu, lists each take in the recordings folders with its size and the projects that use it. Takes can be selected one by one, all at once or only the unused ones, then moved to the Trash with their cursor, session and camera files; if the Trash refuses, a permanent delete is offered. Deleting a take a project still uses names that project first, and a project file that does not parse puts up a warning that usage may be incomplete. The main process only deletes ids that name a take in a recordings folder. --- .../ai-edition/project-recordings.test.ts | 86 ++++- electron/ai-edition/project-recordings.ts | 90 ++++- electron/ipc/nativeBridge.ts | 10 + .../aiEditionService.deleteRecordings.test.ts | 62 ++++ .../services/aiEditionService.ts | 66 +++- .../ai-edition/ManageRecordingsModal.test.tsx | 149 ++++++++ .../ai-edition/ManageRecordingsModal.tsx | 331 ++++++++++++++++++ src/components/ai-edition/NewEditorShell.tsx | 28 ++ .../ai-edition/ProviderSettings.test.tsx | 1 + .../v4/EditorTopBar.stylePresets.test.tsx | 1 + .../ai-edition/v4/EditorTopBar.test.tsx | 9 +- src/components/ai-edition/v4/EditorTopBar.tsx | 11 + src/i18n/locales/ar/editor.json | 24 ++ src/i18n/locales/cs/editor.json | 24 ++ src/i18n/locales/de/editor.json | 24 ++ src/i18n/locales/en/editor.json | 24 ++ src/i18n/locales/es/editor.json | 24 ++ src/i18n/locales/fr/editor.json | 24 ++ src/i18n/locales/it/editor.json | 24 ++ src/i18n/locales/ja-JP/editor.json | 24 ++ src/i18n/locales/ko-KR/editor.json | 24 ++ src/i18n/locales/pt-BR/editor.json | 24 ++ src/i18n/locales/ru/editor.json | 24 ++ src/i18n/locales/tr/editor.json | 24 ++ src/i18n/locales/vi/editor.json | 24 ++ src/i18n/locales/zh-CN/editor.json | 24 ++ src/i18n/locales/zh-TW/editor.json | 24 ++ src/native/browserShim.ts | 3 + src/native/client.ts | 13 + src/native/contracts.ts | 41 +++ .../testing/manual-e2e-checklist.md | 8 + website/docs/media-library.md | 4 + .../current/media-library.md | 4 + .../current/media-library.md | 4 + .../current/media-library.md | 4 + .../current/media-library.md | 4 + .../current/media-library.md | 4 + .../current/media-library.md | 4 + .../current/media-library.md | 4 + 39 files changed, 1296 insertions(+), 5 deletions(-) create mode 100644 src/components/ai-edition/ManageRecordingsModal.test.tsx create mode 100644 src/components/ai-edition/ManageRecordingsModal.tsx diff --git a/electron/ai-edition/project-recordings.test.ts b/electron/ai-edition/project-recordings.test.ts index 06c55e2f0b..d7259a62ce 100644 --- a/electron/ai-edition/project-recordings.test.ts +++ b/electron/ai-edition/project-recordings.test.ts @@ -3,7 +3,12 @@ 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"; +import { + findProjectRecordings, + listRecordings, + removeRecordingSets, + resolveRecordingIds, +} from "./project-recordings"; async function touch(filePath: string, content = "x"): Promise { await fs.writeFile(filePath, content); @@ -137,4 +142,83 @@ describe("project recordings", () => { 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 index 122c7348af..ddf16cfe5e 100644 --- a/electron/ai-edition/project-recordings.ts +++ b/electron/ai-edition/project-recordings.ts @@ -10,6 +10,8 @@ export interface RecordingSet { stem: string; files: string[]; sizeBytes: number; + /** The oldest modification time among its files, in ms. */ + recordedAt: number; } export interface ProjectRecordings { @@ -57,15 +59,23 @@ function documentRecordingKeys(doc: AxcutDocument, recordingsDirs: readonly stri 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; } + 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; @@ -75,11 +85,12 @@ async function readRecordingSet(key: string): Promise { 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 } : null; + return files.length > 0 ? { dir, stem, files, sizeBytes, recordedAt } : null; } /** @@ -135,3 +146,78 @@ export async function removeRecordingSets( } } } + +/** 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/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 2330a0b116..1286412230 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -598,6 +598,16 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { 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( requestId, diff --git a/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts b/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts index 21f1b22b4e..792e7c6d66 100644 --- a/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts +++ b/electron/native-bridge/services/aiEditionService.deleteRecordings.test.ts @@ -128,4 +128,66 @@ describe("AiEditionService — deleting a project's recordings", () => { 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 69c3165a5d..ea7e4ab3d8 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, @@ -17,7 +18,9 @@ import type { AiEditionMcpStatus, AiEditionProjectRecordingsResult, AiEditionProjectSummary, + AiEditionRecordingsDeleteResult, AiEditionRecordingsDisposal, + AiEditionRecordingsListResult, } from "../../../src/native/contracts"; import { type CaptionTranslateSegment, @@ -34,11 +37,23 @@ import { listOpenRouterModels, probeMiniMaxModels, } from "../../ai-edition/llm-provider-auth"; -import { findProjectRecordings, removeRecordingSets } from "../../ai-edition/project-recordings"; +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; deleteChatHistory?: (projectId: string) => Promise; @@ -185,6 +200,55 @@ export class AiEditionService { } } + 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", 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/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 2a417df396..5ade753643 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -63,6 +63,7 @@ import { ExportDialog } from "./ExportDialog"; import { importProjectFile } from "./importProjectFile"; import { insertionsEnabled } from "./insertionsEnabled"; import { ChatStripPanel } from "./LeftPanel"; +import { ManageRecordingsModal } from "./ManageRecordingsModal"; import { EditClipModal, NewProjectModal, @@ -279,6 +280,7 @@ export function NewEditorShell() { const [inspectorOpen, setInspectorOpen] = useState(true); const [facet, setFacet] = useState("effects"); const [openProjectOpen, setOpenProjectOpen] = useState(false); + const [manageRecordingsOpen, setManageRecordingsOpen] = useState(false); const [newProjectOpen, setNewProjectOpen] = useState(false); // Crop + trim in/out both live in EditClipModal now (per-clip), reachable // from the timeline (double-click / pencil icon) and the inspector's @@ -916,6 +918,24 @@ export function NewEditorShell() { [], ); + const handleDeleteRecordings = useCallback( + async (ids: string[], disposal: "trash" | "permanent") => { + const result = await nativeBridgeClient.aiEdition + .deleteRecordings(ids, disposal) + .catch((err: unknown) => ({ + success: false, + trashFailed: false, + error: err instanceof Error ? err.message : String(err), + })); + if (result.success) toast.success(te("recordingsDialog.deleted")); + else if (!result.trashFailed) { + toast.error(te("recordingsDialog.deleteFailed"), { description: result.error }); + } + return result; + }, + [te], + ); + const handleSave = useCallback(async () => { const doc = useProjectStore.getState().document; if (!doc) return; @@ -1652,6 +1672,7 @@ export function NewEditorShell() { chatOpen={chatOpen} actions={{ openProject: () => setOpenProjectOpen(true), + manageRecordings: () => setManageRecordingsOpen(true), newProject: () => setNewProjectOpen(true), save: () => void handleSave(), export: handleExport, @@ -1831,6 +1852,13 @@ export function NewEditorShell() { loadRecordings={nativeBridgeClient.aiEdition.recordings} onBrowse={handleBrowseProject} /> + setManageRecordingsOpen(false)} + loadRecordings={nativeBridgeClient.aiEdition.listRecordings} + deleteRecordings={handleDeleteRecordings} + onReveal={(filePath) => void window.electronAPI?.revealInFolder?.(filePath)} + /> setNewProjectOpen(false)} diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 88103e214d..4842a175dd 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -65,6 +65,7 @@ function TopBar({ mode }: { mode: EditorMode }) { chatOpen={false} actions={{ openProject: noop, + manageRecordings: noop, newProject: noop, save: noop, export: noop, diff --git a/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx b/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx index 320438d1da..01e7e6ab78 100644 --- a/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx @@ -109,6 +109,7 @@ function renderPane() { chatOpen={false} actions={{ openProject: noop, + manageRecordings: noop, newProject: noop, save: noop, export: noop, diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index b9d920cb52..8cf607b6b3 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -51,6 +51,7 @@ function renderTopBar( const onOpenProviderSettings = vi.fn(); const onNewProject = vi.fn(); const onOpenProject = vi.fn(); + const onManageRecordings = vi.fn(); const onSave = vi.fn(); const onUndo = vi.fn(); const onRedo = vi.fn(); @@ -67,6 +68,7 @@ function renderTopBar( chatOpen={false} actions={{ openProject: onOpenProject, + manageRecordings: onManageRecordings, newProject: onNewProject, save: onSave, export: noop, @@ -90,6 +92,7 @@ function renderTopBar( onOpenProviderSettings, onNewProject, onOpenProject, + onManageRecordings, onSave, onUndo, onRedo, @@ -265,7 +268,8 @@ describe("AppMenu", () => { }); it("carries the file actions the bar used to show as three icons", () => { - const { onNewProject, onOpenProject, onSave } = renderTopBar("Demo Project"); + const { onNewProject, onOpenProject, onManageRecordings, onSave } = + renderTopBar("Demo Project"); // Not in the bar any more: only the menu reaches them (and Ctrl+N / Ctrl+O / Ctrl+S). expect(screen.queryByRole("button", { name: "topbar.openProject" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); @@ -275,6 +279,9 @@ describe("AppMenu", () => { fireEvent.click(screen.getByRole("menuitem", { name: "topbar.openProject" })); expect(onOpenProject).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "recordingsDialog.title" })); + expect(onManageRecordings).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); fireEvent.click(screen.getByRole("menuitem", { name: "topbar.saveProject" })); expect(onSave).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index d85a4189e0..136240883d 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -4,6 +4,7 @@ import { Download, FolderOpen, FolderPlus, + HardDrive, Info, Keyboard, Languages, @@ -34,6 +35,7 @@ export type EditorMode = "media" | "edit" | "rec"; export interface TopBarActions { openProject: () => void; + manageRecordings: () => void; newProject: () => void; save: () => void; export: () => void; @@ -434,6 +436,15 @@ function AppMenu({ actions }: { actions: TopBarActions }) { {tEditor("topbar.openProject")} +