From a38454a7fb03a5e736c52abf58a6ed5c280bb63a Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:02:42 +0530 Subject: [PATCH 1/4] feat: update saveExportedVideo fn signature --- electron/electron-env.d.ts | 1 + electron/preload.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..f04b7c3 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/preload.ts b/electron/preload.ts index 46e16f0..ec221b0 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"); From c40727672ffa206d7f57f38a2913906f03dfcbe0 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:05:17 +0530 Subject: [PATCH 2/4] feat: implement handlers to store last export location --- electron/ipc/handlers.ts | 102 ++++++++++++-------- src/components/video-editor/VideoEditor.tsx | 19 +++- src/lib/userPreferences.ts | 18 ++++ 3 files changed, 94 insertions(+), 45 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797..9a8e9ca 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -822,54 +822,72 @@ 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"] }]; + 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"] }]; - 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"], - }); + // Prefer the user's last export folder if it still exists, otherwise fall + // back to ~/Downloads. Validation must happen here because the renderer + // can't stat the filesystem. + let defaultDir = app.getPath("downloads"); + if (exportFolder) { + try { + const stats = await fs.stat(exportFolder); + if (stats.isDirectory()) { + defaultDir = exportFolder; + } + } catch { + // Folder was moved or deleted since the last export; keep Downloads. + } + } - if (result.canceled || !result.filePath) { + const result = await dialog.showSaveDialog({ + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } + + // --- FIX: Normalize the path for Windows compatibility --- + const normalizedPath = path.normalize(result.filePath); + + // 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, - canceled: true, - message: "Export canceled", + message: "Failed to save exported video", + error: String(error), }; } - - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); - - // 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/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558..cf174fa 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,7 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; +import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -1285,6 +1285,10 @@ export default function VideoEditor() { const handleExportSaved = useCallback( (formatLabel: "GIF" | "Video", filePath: string) => { setExportedFilePath(filePath); + const folder = parentDirectoryOf(filePath); + if (folder) { + saveUserPreferences({ exportFolder: folder }); + } toast.success( t("export.exportedSuccessfully", { format: formatLabel, @@ -1309,6 +1313,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, + loadUserPreferences().exportFolder ?? undefined, ); if (saveResult.canceled) { toast.info("Export canceled"); @@ -1410,7 +1415,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + loadUserPreferences().exportFolder ?? undefined, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); @@ -1550,7 +1559,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + loadUserPreferences().exportFolder ?? undefined, + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index e060788..6947da5 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; + /** Folder used for the most recent successful export, if any */ + exportFolder: string | null; } const DEFAULT_PREFS: UserPreferences = { @@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = { aspectRatio: "16:9", exportQuality: "good", exportFormat: "mp4", + exportFolder: null, }; function safeJsonParse(text: string | null): Record | null { @@ -76,9 +79,24 @@ 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 + : DEFAULT_PREFS.exportFolder, }; } +/** + * Extracts the parent directory from a saved file path. Handles both POSIX + * and Windows separators since the path comes from the OS save dialog. + * Returns null if no separator is found. + */ +export function parentDirectoryOf(filePath: string): string | null { + const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); + if (lastSep <= 0) return null; + return filePath.slice(0, lastSep); +} + /** * Persist user preferences to localStorage. * Only the explicitly provided fields are updated. From b801c1ccea42522e752fc9b72a733d492e262400 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Sat, 2 May 2026 01:19:44 +0530 Subject: [PATCH 3/4] fix: resolve comments --- src/lib/userPreferences.test.ts | 26 ++++++++++++++++++++++++++ src/lib/userPreferences.ts | 17 ++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/lib/userPreferences.test.ts diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts new file mode 100644 index 0000000..5ba9fce --- /dev/null +++ b/src/lib/userPreferences.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parentDirectoryOf } from "./userPreferences"; + +describe("parentDirectoryOf", () => { + it("returns the directory for a POSIX path", () => { + expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies"); + }); + + it("returns the directory for a Windows path", () => { + expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies"); + }); + + it("preserves the POSIX root when the file is at /", () => { + expect(parentDirectoryOf("/video.mp4")).toBe("/"); + }); + + it("preserves the Windows drive root with its trailing separator", () => { + expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\"); + expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/"); + }); + + it("returns null when no separator is present", () => { + expect(parentDirectoryOf("video.mp4")).toBeNull(); + expect(parentDirectoryOf("")).toBeNull(); + }); +}); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 6947da5..2c9db6f 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -89,11 +89,26 @@ export function loadUserPreferences(): UserPreferences { /** * Extracts the parent directory from a saved file path. Handles both POSIX * and Windows separators since the path comes from the OS save dialog. + * + * Root directories are preserved with their trailing separator so that the + * value is still a valid directory path: + * "/video.mp4" -> "/" + * "C:\\video.mp4" -> "C:\\" + * * Returns null if no separator is found. */ export function parentDirectoryOf(filePath: string): string | null { const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); - if (lastSep <= 0) return null; + if (lastSep < 0) return null; + + // POSIX root, e.g. "/video.mp4" -> "/" + if (lastSep === 0) return filePath[0]; + + // Windows drive root, e.g. "C:\\video.mp4" -> "C:\\" + if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) { + return filePath.slice(0, lastSep + 1); + } + return filePath.slice(0, lastSep); } From 25cfd2777fa3a10fd93e463b2eb435bc93d2dae0 Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Fri, 8 May 2026 05:24:40 +0530 Subject: [PATCH 4/4] fix: resolve comments --- electron/electron-env.d.ts | 8 +++++++- electron/ipc/handlers.ts | 10 ++++++++-- src/components/video-editor/VideoEditor.tsx | 13 +++++++++---- src/lib/userPreferences.ts | 8 ++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f04b7c3..5e26c44 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; + message?: string; + canceled?: boolean; + error?: string; + }>; 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 9a8e9ca..cb8d217 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -842,8 +842,14 @@ export function registerIpcHandlers( if (stats.isDirectory()) { defaultDir = exportFolder; } - } catch { - // Folder was moved or deleted since the last export; keep Downloads. + } catch (err) { + // Stat can fail because the folder was moved/deleted (expected) or + // because of a permission error (worth surfacing). Either way we + // fall back to Downloads, but log so debugging isn't blind. + console.warn( + `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, + err, + ); } } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cf174fa..46957a3 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,12 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences"; +import { + getExportFolder, + loadUserPreferences, + parentDirectoryOf, + saveUserPreferences, +} from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -1313,7 +1318,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, - loadUserPreferences().exportFolder ?? undefined, + getExportFolder(), ); if (saveResult.canceled) { toast.info("Export canceled"); @@ -1418,7 +1423,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( arrayBuffer, fileName, - loadUserPreferences().exportFolder ?? undefined, + getExportFolder(), ); if (saveResult.canceled) { @@ -1562,7 +1567,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( arrayBuffer, fileName, - loadUserPreferences().exportFolder ?? undefined, + getExportFolder(), ); if (saveResult.canceled) { diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 2c9db6f..5b7bc86 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -112,6 +112,14 @@ export function parentDirectoryOf(filePath: string): string | null { return filePath.slice(0, lastSep); } +/** + * Returns the remembered export folder as `string | undefined`, suitable for + * passing directly to IPC handlers that treat absence as "use the default". + */ +export function getExportFolder(): string | undefined { + return loadUserPreferences().exportFolder ?? undefined; +} + /** * Persist user preferences to localStorage. * Only the explicitly provided fields are updated.