diff --git a/electron/ai-edition/project-recordings.test.ts b/electron/ai-edition/project-recordings.test.ts index 06c55e2f0..d7259a62c 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 122c7348a..ddf16cfe5 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.deleteProject.test.ts b/electron/ipc/nativeBridge.deleteProject.test.ts index abfe8be7b..70f99c6f2 100644 --- a/electron/ipc/nativeBridge.deleteProject.test.ts +++ b/electron/ipc/nativeBridge.deleteProject.test.ts @@ -1,5 +1,6 @@ // Deleting a project takes the recordings disposal from the renderer, and every value but -// "trash" would remove files for good, so the bridge accepts only the known ones. +// "trash" would remove files for good, so the bridge accepts only the known ones. Deleting takes +// from the recordings manager is held to the same rule. import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeBridgeResponse } from "../../src/native/contracts"; @@ -17,18 +18,21 @@ vi.mock("../native-bridge/services/compositorViewService", () => ({ const service = vi.hoisted(() => ({ deleteProject: vi.fn(async () => ({ success: true })), + deleteRecordings: vi.fn(async () => ({ success: true })), })); vi.mock("../native-bridge/services/aiEditionService", () => ({ AiEditionService: class { deleteProject = service.deleteProject; + deleteRecordings = service.deleteRecordings; }, })); -let invoke: (payload: unknown) => Promise; +let invoke: (payload: unknown, action?: string) => Promise; beforeEach(() => { electron.handle.mockClear(); service.deleteProject.mockClear(); + service.deleteRecordings.mockClear(); registerNativeBridgeHandlers({ getPlatform: () => "linux", getAiEditionDocuments: () => ({}), @@ -38,11 +42,8 @@ beforeEach(() => { event: unknown, request: unknown, ) => Promise; - invoke = (payload) => - handler( - { sender: {} }, - { domain: "aiEdition", action: "document.delete", payload, requestId: "r" }, - ); + invoke = (payload, action = "document.delete") => + handler({ sender: {} }, { domain: "aiEdition", action, payload, requestId: "r" }); }); describe("native bridge project delete", () => { @@ -60,3 +61,26 @@ describe("native bridge project delete", () => { expect(service.deleteProject).toHaveBeenCalledWith("p", value); }); }); + +describe("native bridge recordings delete", () => { + it.each([ + { ids: ["a"], disposal: undefined }, + { ids: ["a"], disposal: "keep" }, + { ids: ["a"], disposal: "Trash" }, + { ids: "a", disposal: "trash" }, + { ids: [1], disposal: "trash" }, + { disposal: "permanent" }, + ])("rejects %o as a bad request", async (payload) => { + const response = await invoke(payload, "recordings.delete"); + + expect(response.ok ? undefined : response.error?.code).toBe("INVALID_REQUEST"); + expect(service.deleteRecordings).not.toHaveBeenCalled(); + }); + + it.each(["trash", "permanent"])("passes disposal=%s on", async (disposal) => { + const response = await invoke({ ids: ["a", "b"], disposal }, "recordings.delete"); + + expect(response.ok).toBe(true); + expect(service.deleteRecordings).toHaveBeenCalledWith(["a", "b"], disposal); + }); +}); diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 2330a0b11..1bc5cf7e1 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -598,6 +598,26 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await aiEditionService.projectRecordings(request.payload.projectId), ); + case "recordings.list": + return createSuccessResponse(requestId, await aiEditionService.listRecordings()); + case "recordings.delete": { + const { ids, disposal }: { ids: unknown; disposal: unknown } = request.payload; + if ( + !Array.isArray(ids) || + !ids.every((id) => typeof id === "string") || + (disposal !== "trash" && disposal !== "permanent") + ) { + return createErrorResponse( + requestId, + "INVALID_REQUEST", + "Invalid recordings delete request.", + ); + } + return createSuccessResponse( + requestId, + await aiEditionService.deleteRecordings(ids, 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 21f1b22b4..792e7c6d6 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 69c3165a5..ea7e4ab3d 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 000000000..bab452762 --- /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 000000000..dbc595164 --- /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 2a417df39..5ade75364 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 88103e214..4842a175d 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 320438d1d..01e7e6ab7 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 b9d920cb5..8cf607b6b 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 d85a4189e..136240883 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")} +