feat: Add projectFolder to user preferences

Adds `projectFolder` as a key to user preferences, stores the location of
the most recently opened project, and prefills it in the next File → Open
action. Mirrors the pattern from #512 (`exportFolder`).

Closes #668
This commit is contained in:
Paulo Henrique Garcia
2026-05-29 10:37:27 +02:00
parent cf74b76d39
commit 7cf78fe53c
11 changed files with 139 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
@@ -2609,16 +2609,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>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
getCurrentVideoPathResult: () => ProjectPathResult;
@@ -162,7 +162,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>;
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
getCurrentVideoPathResult: () => ProjectPathResult;
@@ -48,8 +48,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);
},
loadCurrentProjectFile: () => {
return ipcRenderer.invoke("load-current-project-file");
+8 -1
View File
@@ -21,6 +21,7 @@ import {
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
@@ -350,8 +351,14 @@ export function LaunchWindow() {
};
const openProjectFile = async () => {
const result = await nativeBridgeClient.project.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled || !result.success) return;
if (result.path) {
const folder = parentDirectoryOf(result.path);
if (folder) {
saveUserPreferences({ projectFolder: folder });
}
}
await window.electronAPI.switchToEditor();
};
+9 -1
View File
@@ -36,6 +36,7 @@ import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
getExportFolder,
getProjectFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
@@ -712,7 +713,7 @@ export default function VideoEditor() {
}, []);
const handleLoadProject = useCallback(async () => {
const result = await nativeBridgeClient.project.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled) {
return;
@@ -729,6 +730,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]);
+64 -2
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { parentDirectoryOf } from "./userPreferences";
import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_PREFS,
getProjectFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
} from "./userPreferences";
describe("parentDirectoryOf", () => {
it("returns the directory for a POSIX path", () => {
@@ -24,3 +30,59 @@ describe("parentDirectoryOf", () => {
expect(parentDirectoryOf("")).toBeNull();
});
});
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");
});
});
+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;
}
export const DEFAULT_PREFS: UserPreferences = {
@@ -37,6 +39,7 @@ export const DEFAULT_PREFS: UserPreferences = {
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
exportFolder: null,
projectFolder: null,
};
function safeJsonParse(text: string | null): Record<string, unknown> | null {
@@ -87,6 +90,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,
};
}
@@ -124,6 +131,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;
}
| {