Merge pull request #512 from AbhinRustagi/feature/remember-last-export-folder

feat: Add exportFolder to user preferences
This commit is contained in:
Sid
2026-05-08 19:30:56 -07:00
committed by GitHub
6 changed files with 167 additions and 52 deletions
+8 -1
View File
@@ -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: (
+69 -46
View File
@@ -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(
+2 -2
View File
@@ -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");
+21 -3
View File
@@ -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" });
+26
View File
@@ -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();
});
});
+41
View File
@@ -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<string, unknown> | 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.