Merge pull request #512 from AbhinRustagi/feature/remember-last-export-folder
feat: Add exportFolder to user preferences
This commit is contained in:
Vendored
+8
-1
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user