Merge pull request #669 from paulohenriquesg/feat/remember-last-project-folder

feat: Add projectFolder to user preferences
This commit is contained in:
Sid
2026-06-02 23:09:58 -07:00
committed by GitHub
12 changed files with 144 additions and 18 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
loadProjectFile: (projectFolder?: string) => Promise<{
success: boolean;
path?: string;
project?: unknown;
+24 -4
View File
@@ -2622,16 +2622,36 @@ export function registerIpcHandlers(
}
}
ipcMain.handle("load-project-file", async () => {
return loadProjectFile();
ipcMain.handle("load-project-file", async (_, projectFolder?: string) => {
return loadProjectFile(projectFolder);
});
async function loadProjectFile(): Promise<ProjectFileResult> {
async function loadProjectFile(projectFolder?: string): Promise<ProjectFileResult> {
try {
// Prefer the user's last opened-project folder if it still exists,
// otherwise fall back to RECORDINGS_DIR. Validation must happen here
// because the renderer can't stat the filesystem.
let defaultDir = RECORDINGS_DIR;
if (projectFolder) {
try {
const stats = await fs.stat(projectFolder);
if (stats.isDirectory()) {
defaultDir = projectFolder;
}
} 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 RECORDINGS_DIR, but log so debugging isn't blind.
console.warn(
`Could not access remembered project folder "${projectFolder}", falling back to RECORDINGS_DIR:`,
err,
);
}
}
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
defaultPath: defaultDir,
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
+5 -2
View File
@@ -25,7 +25,7 @@ export interface NativeBridgeContext {
suggestedName?: string,
existingProjectPath?: string,
) => Promise<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
@@ -164,7 +164,10 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
),
);
case "loadProjectFile":
return createSuccessResponse(requestId, await projectService.loadProjectFile());
return createSuccessResponse(
requestId,
await projectService.loadProjectFile(request.payload?.projectFolder),
);
case "loadCurrentProjectFile":
return createSuccessResponse(
requestId,
@@ -14,7 +14,7 @@ interface ProjectServiceOptions {
suggestedName?: string,
existingProjectPath?: string,
) => Promise<ProjectFileResult>;
loadProjectFile: () => Promise<ProjectFileResult>;
loadProjectFile: (projectFolder?: string) => Promise<ProjectFileResult>;
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
@@ -49,8 +49,8 @@ export class ProjectService {
return result;
}
async loadProjectFile() {
const result = await this.options.loadProjectFile();
async loadProjectFile(projectFolder?: string) {
const result = await this.options.loadProjectFile(projectFolder);
this.getCurrentContext();
return result;
}
+2 -2
View File
@@ -170,8 +170,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
},
loadProjectFile: () => {
return ipcRenderer.invoke("load-project-file");
loadProjectFile: (projectFolder?: string) => {
return ipcRenderer.invoke("load-project-file", projectFolder);
},
loadProjectFileFromPath: (filePath: string) => {
return ipcRenderer.invoke("load-project-file-from-path", filePath);
+6 -1
View File
@@ -21,13 +21,18 @@ import {
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import {
getProjectFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
} from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { loadUserPreferences, saveUserPreferences } from "../../lib/userPreferences";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Button } from "../ui/button";
@@ -2,6 +2,7 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";
interface EditorEmptyStateProps {
@@ -35,8 +36,14 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp
}, [onVideoImported]);
const handleLoadProject = useCallback(async () => {
const result = await nativeBridgeClient.project.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled || !result.success || !result.project) return;
if (result.path) {
const folder = parentDirectoryOf(result.path);
if (folder) {
saveUserPreferences({ projectFolder: folder });
}
}
onProjectOpened(result.project, result.path ?? null);
}, [onProjectOpened]);
+9 -1
View File
@@ -54,6 +54,7 @@ import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
getExportFolder,
getProjectFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
@@ -755,7 +756,7 @@ export default function VideoEditor() {
}, []);
const doLoadProject = useCallback(async () => {
const result = await nativeBridgeClient.project.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled) {
return;
@@ -772,6 +773,13 @@ export default function VideoEditor() {
return;
}
if (result.path) {
const folder = parentDirectoryOf(result.path);
if (folder) {
saveUserPreferences({ projectFolder: folder });
}
}
toast.success(t("project.loadedFrom", { path: result.path ?? "" }));
}, [applyLoadedProject, t]);
+63 -1
View File
@@ -1,5 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import { loadUserPreferences, parentDirectoryOf, saveUserPreferences } from "./userPreferences";
import {
DEFAULT_PREFS,
getProjectFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
} from "./userPreferences";
describe("parentDirectoryOf", () => {
it("returns the directory for a POSIX path", () => {
@@ -25,6 +31,62 @@ describe("parentDirectoryOf", () => {
});
});
describe("projectFolder preference", () => {
// jsdom's localStorage isn't exposed as a global in this vitest setup, so
// stub it with an in-memory shim before each test. Mirrors what the real
// browser localStorage exposes, scoped to the keys we touch.
beforeEach(() => {
const store = new Map<string, string>();
const stub = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => store.clear(),
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() {
return store.size;
},
};
Object.defineProperty(globalThis, "localStorage", {
value: stub,
configurable: true,
});
});
it("defaults to null when nothing is persisted", () => {
expect(loadUserPreferences().projectFolder).toBeNull();
expect(getProjectFolder()).toBeUndefined();
});
it("round-trips a saved project folder", () => {
saveUserPreferences({ projectFolder: "/Users/me/Projects/demos" });
expect(loadUserPreferences().projectFolder).toBe("/Users/me/Projects/demos");
expect(getProjectFolder()).toBe("/Users/me/Projects/demos");
});
it("ignores non-string persisted values and falls back to the default", () => {
localStorage.setItem("openscreen_user_preferences", JSON.stringify({ projectFolder: 42 }));
expect(loadUserPreferences().projectFolder).toBe(DEFAULT_PREFS.projectFolder);
});
it("ignores empty-string persisted values and falls back to the default", () => {
localStorage.setItem("openscreen_user_preferences", JSON.stringify({ projectFolder: "" }));
expect(loadUserPreferences().projectFolder).toBe(DEFAULT_PREFS.projectFolder);
});
it("is independent of exportFolder", () => {
saveUserPreferences({ exportFolder: "/Users/me/Downloads" });
saveUserPreferences({ projectFolder: "/Users/me/Projects/demos" });
const prefs = loadUserPreferences();
expect(prefs.exportFolder).toBe("/Users/me/Downloads");
expect(prefs.projectFolder).toBe("/Users/me/Projects/demos");
});
});
describe("user preferences", () => {
beforeEach(() => {
localStorage.clear();
+16
View File
@@ -29,6 +29,8 @@ export interface UserPreferences {
exportFormat: ExportFormat;
/** Folder used for the most recent successful export, if any */
exportFolder: string | null;
/** Folder of the most recently opened project, if any */
projectFolder: string | null;
/** Recording HUD control layout */
trayLayout: "horizontal" | "vertical";
}
@@ -39,6 +41,7 @@ export const DEFAULT_PREFS: UserPreferences = {
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
exportFolder: null,
projectFolder: null,
trayLayout: "horizontal",
};
@@ -91,6 +94,10 @@ export function loadUserPreferences(): UserPreferences {
typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
? raw.exportFolder
: DEFAULT_PREFS.exportFolder,
projectFolder:
typeof raw.projectFolder === "string" && raw.projectFolder.length > 0
? raw.projectFolder
: DEFAULT_PREFS.projectFolder,
trayLayout:
raw.trayLayout === "horizontal" || raw.trayLayout === "vertical"
? raw.trayLayout
@@ -132,6 +139,15 @@ export function getExportFolder(): string | undefined {
return loadUserPreferences().exportFolder ?? undefined;
}
/**
* Returns the remembered open-project folder as `string | undefined`,
* suitable for passing directly to IPC handlers that treat absence as
* "use the default".
*/
export function getProjectFolder(): string | undefined {
return loadUserPreferences().projectFolder ?? undefined;
}
/**
* Persist user preferences to localStorage.
* Only the explicitly provided fields are updated.
+2 -1
View File
@@ -84,10 +84,11 @@ export const nativeBridgeClient = {
existingProjectPath,
},
}),
loadProjectFile: () =>
loadProjectFile: (projectFolder?: string) =>
requireNativeBridgeData<ProjectFileResult>({
domain: "project",
action: "loadProjectFile",
payload: { projectFolder },
}),
loadCurrentProjectFile: () =>
requireNativeBridgeData<ProjectFileResult>({
+5 -1
View File
@@ -165,7 +165,11 @@ export type NativeBridgeRequest =
| {
domain: "project";
action: "loadProjectFile";
payload?: EmptyPayload;
payload?: {
/** Folder to pre-fill the open dialog with — typically the user's
* last-opened project folder from userPreferences. */
projectFolder?: string;
};
requestId?: string;
}
| {