diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6d3d9d5..b0a017a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -82,7 +82,14 @@ interface Window { saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + exportFolder?: string, + ) => 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 20154bc..bf0bc97 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -986,58 +986,81 @@ 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 dialogOptions = buildDialogOptions( - { - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }, - getMainWindow(), - ); - const result = await dialog.showSaveDialog(dialogOptions); + // 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 (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, + ); + } + } + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); - if (result.canceled || !result.filePath) { + 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 dialogOptions = buildDialogOptions( diff --git a/electron/preload.ts b/electron/preload.ts index 9149756..9a37974 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -71,8 +71,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 bdcd57b..ec613a8 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, saveUserPreferences } from "@/lib/userPreferences"; +import { + getExportFolder, + loadUserPreferences, + parentDirectoryOf, + saveUserPreferences, +} from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -1319,6 +1324,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, @@ -1343,6 +1352,7 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, + getExportFolder(), ); if (saveResult.canceled) { toast.info("Export canceled"); @@ -1446,7 +1456,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + getExportFolder(), + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); @@ -1588,7 +1602,11 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + getExportFolder(), + ); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); 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 e060788..5b7bc86 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,47 @@ 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. + * + * 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; + + // 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); +} + +/** + * 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.