From 84952f3091144e1c85a71aa065ce70a0b7109673 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 00:54:41 +0200 Subject: [PATCH 1/2] feat: remember last export folder across sessions The save dialog now opens in the last folder the user exported to, instead of always defaulting to Downloads. The folder is persisted in userPreferences (localStorage) and passed through the IPC layer to the Electron save dialog as defaultPath. Closes #503 --- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 91 +++++++++++---------- electron/preload.ts | 4 +- src/components/video-editor/VideoEditor.tsx | 37 +++++++-- src/lib/userPreferences.ts | 6 ++ 5 files changed, 89 insertions(+), 50 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d82946d..f04b7c337 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -76,6 +76,7 @@ interface Window { saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, + exportFolder?: string, ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797e7..a44cc8eef 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -822,54 +822,59 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { - try { - // Determine file type from extension - const isGif = fileName.toLowerCase().endsWith(".gif"); - const filters = isGif - ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] - : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - - const result = await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + ipcMain.handle( + "save-exported-video", + async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { + try { + // Determine file type from extension + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: "Export canceled", - }; - } + const baseFolder = exportFolder ?? app.getPath("downloads"); - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); + const result = await dialog.showSaveDialog({ + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(baseFolder, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }); - // Ensure the parent directory exists (Windows may fail if the folder is missing) - await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } - await fs.writeFile(normalizedPath, Buffer.from(videoData)); + // --- FIX: Normalize the path for Windows compatibility --- + const normalizedPath = path.normalize(result.filePath); - return { - success: true, - path: normalizedPath, - message: "Video exported successfully", - }; - } catch (error) { - console.error("Failed to save exported video:", error); - return { - success: false, - message: "Failed to save exported video", - error: String(error), - }; - } - }); + // Ensure the parent directory exists (Windows may fail if the folder is missing) + await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); + // --- END FIX --- + + await fs.writeFile(normalizedPath, Buffer.from(videoData)); + + return { + success: true, + path: normalizedPath, + message: "Video exported successfully", + }; + } catch (error) { + console.error("Failed to save exported video:", error); + return { + success: false, + message: "Failed to save exported video", + error: String(error), + }; + } + }, + ); ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f043..ec221b06c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -68,8 +68,8 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558e8..879c8557d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -133,6 +133,7 @@ export default function VideoEditor() { const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); const [exportQuality, setExportQuality] = useState("good"); const [exportFormat, setExportFormat] = useState("mp4"); + const [exportFolder, setExportFolder] = useState(undefined); const [gifFrameRate, setGifFrameRate] = useState(15); const [gifLoop, setGifLoop] = useState(true); const [gifSizePreset, setGifSizePreset] = useState("medium"); @@ -409,14 +410,15 @@ export default function VideoEditor() { }); setExportQuality(prefs.exportQuality); setExportFormat(prefs.exportFormat); + setExportFolder(prefs.exportFolder); setPrefsHydrated(true); }, [updateState]); // Auto-save user preferences when settings change useEffect(() => { if (!prefsHydrated) return; - saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat }); - }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]); + saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat, exportFolder }); + }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat, exportFolder]); const saveProject = useCallback( async (forceSaveAs: boolean) => { @@ -1309,10 +1311,16 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, + exportFolder, ); if (saveResult.canceled) { toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { + const folder = saveResult.path.substring( + 0, + Math.max(saveResult.path.lastIndexOf("/"), saveResult.path.lastIndexOf("\\")), + ); + setExportFolder(folder); setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { @@ -1322,7 +1330,7 @@ export default function VideoEditor() { console.error("Error saving unsaved export:", error); toast.error("Failed to save exported video"); } - }, [unsavedExport, handleExportSaved]); + }, [unsavedExport, exportFolder, handleExportSaved]); const handleExport = useCallback( async (settings: ExportSettings) => { @@ -1410,12 +1418,21 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + exportFolder, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { + const folder = saveResult.path.substring( + 0, + Math.max(saveResult.path.lastIndexOf("/"), saveResult.path.lastIndexOf("\\")), + ); + setExportFolder(folder); setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { @@ -1550,12 +1567,21 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + exportFolder, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { + const folder = saveResult.path.substring( + 0, + Math.max(saveResult.path.lastIndexOf("/"), saveResult.path.lastIndexOf("\\")), + ); + setExportFolder(folder); setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else { @@ -1612,6 +1638,7 @@ export default function VideoEditor() { webcamSizePreset, webcamPosition, exportQuality, + exportFolder, handleExportSaved, cursorTelemetry, t, diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index e0607880f..26bec7b24 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -23,6 +23,8 @@ export interface UserPreferences { exportQuality: ExportQuality; /** Default export format */ exportFormat: ExportFormat; + /** Last folder used when saving an export */ + exportFolder?: string; } const DEFAULT_PREFS: UserPreferences = { @@ -76,6 +78,10 @@ export function loadUserPreferences(): UserPreferences { raw.exportFormat === "gif" || raw.exportFormat === "mp4" ? (raw.exportFormat as ExportFormat) : DEFAULT_PREFS.exportFormat, + exportFolder: + typeof raw.exportFolder === "string" && raw.exportFolder.length > 0 + ? raw.exportFolder + : undefined, }; } From 7b2fe8a703a2fcb6dc057bad50c6f51dcb3ef1d0 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 01:14:55 +0200 Subject: [PATCH 2/2] fix: use path.dirname from IPC handler instead of renderer path parsing Addresses code review feedback: the previous substring/lastIndexOf approach would store empty string as export folder if saved to root directory. path.dirname in the Electron handler handles edge cases correctly and is returned as a new dir field in the IPC response. --- electron/electron-env.d.ts | 8 +++++++- electron/ipc/handlers.ts | 1 + src/components/video-editor/VideoEditor.tsx | 18 +++--------------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f04b7c337..94fd97307 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -77,7 +77,13 @@ interface Window { videoData: ArrayBuffer, fileName: string, exportFolder?: string, - ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + ) => Promise<{ + success: boolean; + path?: string; + dir?: string; + message?: string; + canceled?: boolean; + }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index a44cc8eef..2668970cb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -863,6 +863,7 @@ export function registerIpcHandlers( return { success: true, path: normalizedPath, + dir: path.dirname(normalizedPath), message: "Video exported successfully", }; } catch (error) { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 879c8557d..0cf6f2504 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1316,11 +1316,7 @@ export default function VideoEditor() { if (saveResult.canceled) { toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { - const folder = saveResult.path.substring( - 0, - Math.max(saveResult.path.lastIndexOf("/"), saveResult.path.lastIndexOf("\\")), - ); - setExportFolder(folder); + if (saveResult.dir) setExportFolder(saveResult.dir); setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { @@ -1428,11 +1424,7 @@ export default function VideoEditor() { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { - const folder = saveResult.path.substring( - 0, - Math.max(saveResult.path.lastIndexOf("/"), saveResult.path.lastIndexOf("\\")), - ); - setExportFolder(folder); + if (saveResult.dir) setExportFolder(saveResult.dir); setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { @@ -1577,11 +1569,7 @@ export default function VideoEditor() { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { - const folder = saveResult.path.substring( - 0, - Math.max(saveResult.path.lastIndexOf("/"), saveResult.path.lastIndexOf("\\")), - ); - setExportFolder(folder); + if (saveResult.dir) setExportFolder(saveResult.dir); setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else {