From 337569739cb10beaf263f222e76b023f54a0a369 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:46:07 +1000 Subject: [PATCH 1/6] fix(editor): port project save UX and clip cleanup to main --- electron/electron-env.d.ts | 11 + electron/ipc/register/project.ts | 65 ++++- electron/preload.ts | 12 + src/components/video-editor/VideoEditor.tsx | 240 ++++++++++++++---- .../video-editor/timeline/AudioWaveform.tsx | 2 +- src/components/video-editor/timeline/Row.tsx | 2 +- 6 files changed, 274 insertions(+), 58 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eff6eced..9e0adb6f0 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -345,6 +345,17 @@ interface Window { canceled?: boolean; error?: string; }>; + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; loadProjectFile: () => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index dee0af163..9310dc2df 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -41,6 +41,29 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } +function normalizeProjectSaveName(projectName?: string | null) { + if (typeof projectName !== "string") { + return null; + } + + const trimmedName = projectName.trim(); + if (!trimmedName) { + return null; + } + + const withoutExtension = trimmedName.replace( + new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + "", + ); + const sanitizedName = withoutExtension + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + + return sanitizedName || null; +} + export function registerProjectHandlers() { ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { try { @@ -142,10 +165,8 @@ export function registerProjectHandlers() { } } - const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_') - const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) - ? safeName - : `${safeName}.${PROJECT_FILE_EXTENSION}` + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` + const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` const result = await dialog.showSaveDialog({ title: 'Save Recordly Project', @@ -185,6 +206,42 @@ export function registerProjectHandlers() { } }) + ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => { + try { + const normalizedProjectName = normalizeProjectSaveName(projectName) + if (!normalizedProjectName) { + return { + success: false, + message: 'Project name is required', + } + } + + const projectsDir = await getProjectsDir() + const targetProjectPath = path.join( + projectsDir, + `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, + ) + + await fs.writeFile(targetProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + setCurrentProjectPath(targetProjectPath) + await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) + await rememberRecentProject(targetProjectPath) + + return { + success: true, + path: targetProjectPath, + message: 'Project saved successfully' + } + } catch (error) { + console.error('Failed to save named project file:', error) + return { + success: false, + message: 'Failed to save project file', + error: String(error) + } + } + }) + ipcMain.handle('load-project-file', async () => { try { const projectsDir = await getProjectsDir() diff --git a/electron/preload.ts b/electron/preload.ts index 4ae5d8bf8..fe226ba0d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -407,6 +407,18 @@ contextBridge.exposeInMainWorld("electronAPI", { thumbnailDataUrl, ); }, + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => { + return ipcRenderer.invoke( + "save-project-file-named", + projectData, + projectName, + thumbnailDataUrl, + ); + }, loadProjectFile: () => { return ipcRenderer.invoke("load-project-file"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 42debcad4..ffe7e4c91 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,7 +14,6 @@ import { Plus, PuzzlePiece, ArrowClockwise as Redo2, - FloppyDisk as Save, Scissors, SkipBack, SkipForward, @@ -485,6 +484,9 @@ export default function VideoEditor() { const [currentProjectPath, setCurrentProjectPath] = useState(null); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [isEditingProjectName, setIsEditingProjectName] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [isSavingProjectName, setIsSavingProjectName] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -631,6 +633,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const projectBrowserTriggerRef = useRef(null); const projectBrowserFallbackTriggerRef = useRef(null); + const projectNameInputRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextClipIdRef = useRef(1); @@ -1240,6 +1243,27 @@ export default function VideoEditor() { return withoutExtension || t("editor.project.untitled", "Untitled"); }, [currentProjectPath, currentSourcePath, t]); + useEffect(() => { + if (!isEditingProjectName) { + setProjectNameDraft(projectDisplayName); + } + }, [isEditingProjectName, projectDisplayName]); + + useEffect(() => { + if (!isEditingProjectName) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [isEditingProjectName]); + const currentPersistedEditorState = useMemo( () => buildPersistedEditorState({ @@ -2178,6 +2202,91 @@ export default function VideoEditor() { } }, [saveProject]); + const saveProjectWithName = useCallback( + async (projectName: string) => { + const trimmedProjectName = projectName.trim(); + if (!trimmedProjectName) { + toast.error("Project name is required"); + return false; + } + + if (!currentSourcePath) { + toast.error("No video loaded"); + return false; + } + + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData(currentSourcePath, currentPersistedEditorState); + const thumbnailDataUrl = await captureProjectThumbnail(); + const result = await window.electronAPI.saveProjectFileNamed( + projectData, + trimmedProjectName, + thumbnailDataUrl, + ); + + if (result.canceled) { + toast.info("Project save canceled"); + return false; + } + + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return false; + } + + if (result.path) { + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot(cloneStructured(projectData)); + await refreshProjectLibrary(); + toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); + return true; + } finally { + remountPreview(); + } + }, + [ + captureProjectThumbnail, + currentPersistedEditorState, + currentProjectSnapshot, + currentSourcePath, + refreshProjectLibrary, + remountPreview, + ], + ); + + const closeProjectNameEditor = useCallback(() => { + setProjectNameDraft(projectDisplayName); + setIsEditingProjectName(false); + }, [projectDisplayName]); + + const handleProjectNameSubmit = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedProjectName = projectNameDraft.trim(); + if (!trimmedProjectName) { + closeProjectNameEditor(); + return; + } + + setIsSavingProjectName(true); + const saved = await saveProjectWithName(trimmedProjectName); + setIsSavingProjectName(false); + + if (saved) { + setIsEditingProjectName(false); + return; + } + + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }, + [closeProjectNameEditor, projectNameDraft, saveProjectWithName], + ); + const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { const result = await window.electronAPI.openProjectFileAtPath(projectPath); @@ -2808,12 +2917,31 @@ export default function VideoEditor() { const handleClipDelete = useCallback( (id: string) => { + const deletedClip = clipRegions.find((clip) => clip.id === id); setClipRegions((prev) => prev.filter((clip) => clip.id !== id)); + if (deletedClip) { + const { startMs, endMs } = deletedClip; + setZoomRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setAnnotationRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setTrimRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setSpeedRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setAudioRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + } if (selectedClipId === id) { setSelectedClipId(null); } }, - [selectedClipId], + [clipRegions, selectedClipId], ); const handleSelectSpeed = useCallback((id: string | null) => { @@ -4138,17 +4266,6 @@ export default function VideoEditor() { return top > 0 || left > 0 || bottom > 0 || right > 0; }, [cropRegion]); - const openRecordingsFolder = useCallback(async () => { - try { - const result = await window.electronAPI.openRecordingsFolder(); - if (!result.success) { - toast.error(result.message || result.error || "Failed to open recordings folder."); - } - } catch (error) { - toast.error(`Failed to open recordings folder: ${String(error)}`); - } - }, []); - const revealExportedFile = useCallback(async () => { if (!exportedFilePath) return; @@ -4293,13 +4410,14 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > @@ -4330,48 +4448,66 @@ export default function VideoEditor() {
- - {projectDisplayName} - - - .recordly - + {isEditingProjectName ? ( +
void handleProjectNameSubmit(event)} + className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]" + > + {hasUnsavedChanges ? ( + + ) : null} + setProjectNameDraft(event.target.value)} + onBlur={() => { + if (!isSavingProjectName) { + closeProjectNameEditor(); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeProjectNameEditor(); + } + }} + disabled={isSavingProjectName} + className="min-w-[10ch] max-w-[min(40vw,360px)] bg-transparent text-sm font-semibold tracking-tight text-foreground/95 outline-none placeholder:text-muted-foreground/60 disabled:cursor-wait" + style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }} + aria-label={t("editor.project.renameInput", "Project name")} + /> + + .recordly + + + ) : ( + + )}
- - -
); } diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index a59286e77..f2aba7238 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -30,7 +30,7 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = " {hint}
)} -
+
{children}
From 9a4c5155a9bcc4f8dd97f5e64ef559c86e4b39d6 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:59:09 +1000 Subject: [PATCH 2/6] fix(editor): address PR review feedback --- electron/ipc/register/project.ts | 76 ++++++++++++++++++++- src/components/video-editor/VideoEditor.tsx | 27 ++++++-- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 9310dc2df..e3fbd5024 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -41,6 +41,9 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } +/** + * Produces a filesystem-safe project base name without the project extension. + */ function normalizeProjectSaveName(projectName?: string | null) { if (typeof projectName !== "string") { return null; @@ -55,8 +58,11 @@ function normalizeProjectSaveName(projectName?: string | null) { new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), "", ); - const sanitizedName = withoutExtension - .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "") + const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); + const withoutControlChars = Array.from(withoutInvalidFilesystemChars) + .filter((character) => character.charCodeAt(0) > 31) + .join(""); + const sanitizedName = withoutControlChars .replace(/\s+/g, " ") .replace(/[. ]+$/g, "") .trim(); @@ -64,6 +70,64 @@ function normalizeProjectSaveName(projectName?: string | null) { return sanitizedName || null; } +/** + * Extracts the persisted source video path from a saved project payload. + */ +function getProjectVideoPath(projectData: unknown) { + if (!projectData || typeof projectData !== "object") { + return null; + } + + const candidate = projectData as { videoPath?: unknown }; + return typeof candidate.videoPath === "string" ? candidate.videoPath : null; +} + +/** + * Prevents a named save from silently overwriting a different project file. + */ +async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( + targetProjectPath: string, + projectData: unknown, +) { + try { + await fs.stat(targetProjectPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return { success: true }; + } + throw error; + } + + const incomingVideoPath = getProjectVideoPath(projectData); + if (!incomingVideoPath) { + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } + + try { + const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); + const existingProjectData = JSON.parse(existingProjectRaw) as unknown; + const existingVideoPath = getProjectVideoPath(existingProjectData); + + if (existingVideoPath === incomingVideoPath) { + return { success: true }; + } + } catch (error) { + console.error("Failed to verify existing named project before overwrite:", error); + return { + success: false, + message: "A different project already uses this name", + }; + } + + return { + success: false, + message: "A different project already uses this name", + }; +} + export function registerProjectHandlers() { ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { try { @@ -222,6 +286,14 @@ export function registerProjectHandlers() { `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, ) + const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( + targetProjectPath, + projectData, + ) + if (!overwriteCheck.success) { + return overwriteCheck + } + await fs.writeFile(targetProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') setCurrentProjectPath(targetProjectPath) await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ffe7e4c91..f8dc05970 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2202,6 +2202,9 @@ export default function VideoEditor() { } }, [saveProject]); + /** + * Saves the current project directly into the projects library under a chosen name. + */ const saveProjectWithName = useCallback( async (projectName: string) => { const trimmedProjectName = projectName.trim(); @@ -2258,11 +2261,17 @@ export default function VideoEditor() { ], ); + /** + * Resets the inline project-name editor back to the current saved display name. + */ const closeProjectNameEditor = useCallback(() => { setProjectNameDraft(projectDisplayName); setIsEditingProjectName(false); }, [projectDisplayName]); + /** + * Commits the inline project-name editor and persists the project under that name. + */ const handleProjectNameSubmit = useCallback( async (event?: React.FormEvent) => { event?.preventDefault(); @@ -2273,8 +2282,12 @@ export default function VideoEditor() { } setIsSavingProjectName(true); - const saved = await saveProjectWithName(trimmedProjectName); - setIsSavingProjectName(false); + let saved = false; + try { + saved = await saveProjectWithName(trimmedProjectName); + } finally { + setIsSavingProjectName(false); + } if (saved) { setIsEditingProjectName(false); @@ -2922,19 +2935,19 @@ export default function VideoEditor() { if (deletedClip) { const { startMs, endMs } = deletedClip; setZoomRegions((prev) => - prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); setAnnotationRegions((prev) => - prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); setTrimRegions((prev) => - prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); setSpeedRegions((prev) => - prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); setAudioRegions((prev) => - prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); } if (selectedClipId === id) { From 1f5a425d34e78ef2711e28fe5f8c9eb9f9228750 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:44:10 +1000 Subject: [PATCH 3/6] Protect projectId from overwrite follow-up --- electron/electron-env.d.ts | 2 + electron/ipc/register/project.ts | 111 +++++++++++++++--- src/components/video-editor/VideoEditor.tsx | 48 ++++++-- .../video-editor/projectPersistence.ts | 6 + 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 9e0adb6f0..c24386a04 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -341,6 +341,7 @@ interface Window { ) => Promise<{ success: boolean; path?: string; + projectId?: string; message?: string; canceled?: boolean; error?: string; @@ -352,6 +353,7 @@ interface Window { ) => Promise<{ success: boolean; path?: string; + projectId?: string; message?: string; canceled?: boolean; error?: string; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index e3fbd5024..b041f7df4 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -1,4 +1,5 @@ import { constants as fsConstants } from "node:fs"; +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { dialog, ipcMain, shell } from "electron"; @@ -82,12 +83,55 @@ function getProjectVideoPath(projectData: unknown) { return typeof candidate.videoPath === "string" ? candidate.videoPath : null; } +function getProjectId(projectData: unknown) { + if (!projectData || typeof projectData !== "object") { + return null; + } + + const candidate = projectData as { projectId?: unknown }; + return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0 + ? candidate.projectId + : null; +} + +function withProjectId(projectData: unknown, projectId: string) { + if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { + return projectData; + } + + return { + ...projectData, + projectId, + }; +} + +function ensureProjectDataHasProjectId(projectData: unknown) { + const existingProjectId = getProjectId(projectData); + if (existingProjectId) { + return { + projectId: existingProjectId, + projectData, + }; + } + + const projectId = randomUUID(); + return { + projectId, + projectData: withProjectId(projectData, projectId), + }; +} + +async function resolveComparablePath(filePath: string) { + return fs.realpath(filePath).catch(() => path.resolve(filePath)); +} + /** * Prevents a named save from silently overwriting a different project file. */ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( targetProjectPath: string, projectData: unknown, + activeProjectPath?: string | null, ) { try { await fs.stat(targetProjectPath); @@ -98,34 +142,59 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( throw error; } - const incomingVideoPath = getProjectVideoPath(projectData); - if (!incomingVideoPath) { - return { - success: false, - message: "Unable to verify project identity for the chosen name", - }; + const targetResolvedPath = await resolveComparablePath(targetProjectPath); + if (activeProjectPath) { + const activeResolvedPath = await resolveComparablePath(activeProjectPath); + if (activeResolvedPath === targetResolvedPath) { + return { success: true }; + } } + const incomingProjectId = getProjectId(projectData); + const incomingVideoPath = getProjectVideoPath(projectData); + try { const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); const existingProjectData = JSON.parse(existingProjectRaw) as unknown; + const existingProjectId = getProjectId(existingProjectData); const existingVideoPath = getProjectVideoPath(existingProjectData); - if (existingVideoPath === incomingVideoPath) { - return { success: true }; + if (existingProjectId && incomingProjectId) { + if (existingProjectId === incomingProjectId) { + return { success: true }; + } + + return { + success: false, + message: "A different project already uses this name", + }; + } + + if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) { + return { + success: false, + message: "A different project already uses this name", + }; } + + if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) { + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } + + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; } catch (error) { console.error("Failed to verify existing named project before overwrite:", error); return { success: false, - message: "A different project already uses this name", + message: "Unable to verify project identity for the chosen name", }; } - - return { - success: false, - message: "A different project already uses this name", - }; } export function registerProjectHandlers() { @@ -213,18 +282,20 @@ export function registerProjectHandlers() { ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => { try { const projectsDir = await getProjectsDir() + const preparedProject = ensureProjectDataHasProjectId(projectData) const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) ? existingProjectPath : null if (trustedExistingProjectPath) { - await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + await fs.writeFile(trustedExistingProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') setCurrentProjectPath(trustedExistingProjectPath) await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl) await rememberRecentProject(trustedExistingProjectPath) return { success: true, path: trustedExistingProjectPath, + projectId: preparedProject.projectId, message: 'Project saved successfully' } } @@ -250,7 +321,7 @@ export function registerProjectHandlers() { } } - await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8') + await fs.writeFile(result.filePath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') setCurrentProjectPath(result.filePath) await saveProjectThumbnail(result.filePath, thumbnailDataUrl) await rememberRecentProject(result.filePath) @@ -258,6 +329,7 @@ export function registerProjectHandlers() { return { success: true, path: result.filePath, + projectId: preparedProject.projectId, message: 'Project saved successfully' } } catch (error) { @@ -281,6 +353,7 @@ export function registerProjectHandlers() { } const projectsDir = await getProjectsDir() + const preparedProject = ensureProjectDataHasProjectId(projectData) const targetProjectPath = path.join( projectsDir, `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, @@ -288,13 +361,14 @@ export function registerProjectHandlers() { const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( targetProjectPath, - projectData, + preparedProject.projectData, + currentProjectPath, ) if (!overwriteCheck.success) { return overwriteCheck } - await fs.writeFile(targetProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') setCurrentProjectPath(targetProjectPath) await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) await rememberRecentProject(targetProjectPath) @@ -302,6 +376,7 @@ export function registerProjectHandlers() { return { success: true, path: targetProjectPath, + projectId: preparedProject.projectId, message: 'Project saved successfully' } } catch (error) { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f8dc05970..92aaf29e5 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1592,7 +1592,11 @@ export default function VideoEditor() { setLastSavedSnapshot( cloneStructured( - createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor)), + createProjectData( + sourcePath, + buildPersistedEditorState(normalizedEditor), + project.projectId ?? null, + ), ), ); await refreshProjectLibrary(); @@ -1605,8 +1609,12 @@ export default function VideoEditor() { if (!currentSourcePath) { return null; } - return createProjectData(currentSourcePath, currentPersistedEditorState); - }, [currentPersistedEditorState, currentSourcePath]); + return createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); + }, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]); const syncRecordingSessionWebcam = useCallback( async (webcamPath: string | null) => { @@ -2120,7 +2128,11 @@ export default function VideoEditor() { const projectData = currentProjectSnapshot?.videoPath === currentSourcePath ? currentProjectSnapshot - : createProjectData(currentSourcePath, currentPersistedEditorState); + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); const fileNameBase = currentSourcePath @@ -2159,7 +2171,15 @@ export default function VideoEditor() { if (result.path) { setCurrentProjectPath(result.path); } - setLastSavedSnapshot(cloneStructured(projectData)); + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), + ), + ); await refreshProjectLibrary(); toast.success(`Project saved to ${result.path}`); @@ -2174,6 +2194,7 @@ export default function VideoEditor() { currentProjectPath, currentProjectSnapshot, currentPersistedEditorState, + lastSavedSnapshot?.projectId, refreshProjectLibrary, remountPreview, ], @@ -2222,7 +2243,11 @@ export default function VideoEditor() { const projectData = currentProjectSnapshot?.videoPath === currentSourcePath ? currentProjectSnapshot - : createProjectData(currentSourcePath, currentPersistedEditorState); + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); const thumbnailDataUrl = await captureProjectThumbnail(); const result = await window.electronAPI.saveProjectFileNamed( projectData, @@ -2243,7 +2268,15 @@ export default function VideoEditor() { if (result.path) { setCurrentProjectPath(result.path); } - setLastSavedSnapshot(cloneStructured(projectData)); + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), + ), + ); await refreshProjectLibrary(); toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); return true; @@ -2256,6 +2289,7 @@ export default function VideoEditor() { currentPersistedEditorState, currentProjectSnapshot, currentSourcePath, + lastSavedSnapshot?.projectId, refreshProjectLibrary, remountPreview, ], diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 71823988a..04bac7657 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -118,6 +118,7 @@ export interface ProjectEditorState { export interface EditorProjectData { version: number; + projectId?: string; videoPath: string; editor: Partial; } @@ -281,6 +282,7 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj if (!candidate || typeof candidate !== "object") return false; const project = candidate as Partial; if (typeof project.version !== "number") return false; + if (project.projectId !== undefined && typeof project.projectId !== "string") return false; if (typeof project.videoPath !== "string" || !project.videoPath) return false; if (!project.editor || typeof project.editor !== "object") return false; return true; @@ -861,9 +863,13 @@ export function normalizeProjectEditor(editor: Partial): Pro export function createProjectData( videoPath: string, editor: Partial, + projectId?: string | null, ): EditorProjectData { return { version: PROJECT_VERSION, + ...(typeof projectId === "string" && projectId.trim().length > 0 + ? { projectId } + : {}), videoPath, editor, }; From 916307df075898d9dca848f6ce9a3e91867ac6da Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:51:12 +1000 Subject: [PATCH 4/6] Clean up dependent regions on clip resize --- src/components/video-editor/VideoEditor.tsx | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 92aaf29e5..4a9445742 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2885,6 +2885,16 @@ export default function VideoEditor() { const oldClip = clipRegions.find((c) => c.id === id); const newStart = Math.round(span.start); const newEnd = Math.round(span.end); + const removedSegments = oldClip + ? [ + ...(newStart > oldClip.startMs + ? [{ startMs: oldClip.startMs, endMs: newStart }] + : []), + ...(newEnd < oldClip.endMs + ? [{ startMs: newEnd, endMs: oldClip.endMs }] + : []), + ] + : []; if (oldClip) { const startDelta = newStart - oldClip.startMs; @@ -2910,6 +2920,23 @@ export default function VideoEditor() { } } + if (removedSegments.length > 0) { + const removeTrimmedRegions = ( + regions: T[], + ): T[] => + regions.filter( + (region) => + !removedSegments.some( + (segment) => region.startMs < segment.endMs && region.endMs > segment.startMs, + ), + ); + setZoomRegions((prev) => removeTrimmedRegions(prev)); + setAnnotationRegions((prev) => removeTrimmedRegions(prev)); + setTrimRegions((prev) => removeTrimmedRegions(prev)); + setSpeedRegions((prev) => removeTrimmedRegions(prev)); + setAudioRegions((prev) => removeTrimmedRegions(prev)); + } + setClipRegions((prev) => prev.map((clip) => clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip, From ce970adf5afc16c7588ad5a9c4a23ff356553524 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:58:56 +1000 Subject: [PATCH 5/6] Cleanup rename and trim handling --- electron/ipc/register/project.ts | 37 +++++++++++++++++++-- src/components/video-editor/VideoEditor.tsx | 4 --- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index b041f7df4..29f138dc9 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -19,14 +19,17 @@ import { } from "../state"; import { getProjectsDir, + getProjectThumbnailPath, isAllowedLocalMediaPath, isPathInsideDirectory, isTrustedProjectPath, listProjectLibraryEntries, + loadRecentProjectPaths, loadProjectFromPath, persistRecordingsDirectorySetting, replaceApprovedSessionLocalReadPaths, rememberRecentProject, + saveRecentProjectPaths, saveProjectThumbnail, } from "../project/manager"; import { @@ -354,6 +357,9 @@ export function registerProjectHandlers() { const projectsDir = await getProjectsDir() const preparedProject = ensureProjectDataHasProjectId(projectData) + const activeProjectPath = isTrustedProjectPath(currentProjectPath) + ? currentProjectPath + : null const targetProjectPath = path.join( projectsDir, `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, @@ -362,17 +368,44 @@ export function registerProjectHandlers() { const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( targetProjectPath, preparedProject.projectData, - currentProjectPath, + activeProjectPath, ) if (!overwriteCheck.success) { return overwriteCheck } await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') - setCurrentProjectPath(targetProjectPath) await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) await rememberRecentProject(targetProjectPath) + if (activeProjectPath) { + const [activeResolvedPath, targetResolvedPath] = await Promise.all([ + resolveComparablePath(activeProjectPath), + resolveComparablePath(targetProjectPath), + ]) + + if (activeResolvedPath !== targetResolvedPath) { + await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => { + if (unlinkError.code !== 'ENOENT') { + throw unlinkError + } + }) + await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined) + + const recentProjectPaths = await loadRecentProjectPaths() + const filteredRecentProjectPaths: string[] = [] + for (const recentProjectPath of recentProjectPaths) { + const recentResolvedPath = await resolveComparablePath(recentProjectPath) + if (recentResolvedPath !== activeResolvedPath) { + filteredRecentProjectPaths.push(recentProjectPath) + } + } + await saveRecentProjectPaths(filteredRecentProjectPaths) + } + } + + setCurrentProjectPath(targetProjectPath) + return { success: true, path: targetProjectPath, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4a9445742..f48489c67 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2932,7 +2932,6 @@ export default function VideoEditor() { ); setZoomRegions((prev) => removeTrimmedRegions(prev)); setAnnotationRegions((prev) => removeTrimmedRegions(prev)); - setTrimRegions((prev) => removeTrimmedRegions(prev)); setSpeedRegions((prev) => removeTrimmedRegions(prev)); setAudioRegions((prev) => removeTrimmedRegions(prev)); } @@ -3001,9 +3000,6 @@ export default function VideoEditor() { setAnnotationRegions((prev) => prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); - setTrimRegions((prev) => - prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), - ); setSpeedRegions((prev) => prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), ); From 25f6258f3a1218227871c904714099d9e175e2d6 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:14:10 +1000 Subject: [PATCH 6/6] Handle rename errors and prompt to save unsaved recordings --- src/components/video-editor/VideoEditor.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f48489c67..5c1b760bd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1704,12 +1704,11 @@ export default function VideoEditor() { const hasUnsavedChanges = useMemo( () => Boolean( - currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot), + currentProjectSnapshot && + (!lastSavedSnapshot || + !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)), ), - [currentProjectPath, currentProjectSnapshot, lastSavedSnapshot], + [currentProjectSnapshot, lastSavedSnapshot], ); useEffect(() => { @@ -2319,6 +2318,8 @@ export default function VideoEditor() { let saved = false; try { saved = await saveProjectWithName(trimmedProjectName); + } catch (error) { + toast.error(getErrorMessage(error)); } finally { setIsSavingProjectName(false); }