From 7cf78fe53c944370541b822bd91d63dc232a1d0a Mon Sep 17 00:00:00 2001 From: Paulo Henrique Garcia Date: Fri, 29 May 2026 10:37:27 +0200 Subject: [PATCH 1/2] feat: Add projectFolder to user preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/electron-env.d.ts | 2 +- electron/ipc/handlers.ts | 28 ++++++-- electron/ipc/nativeBridge.ts | 7 +- .../native-bridge/services/projectService.ts | 6 +- electron/preload.ts | 4 +- src/components/launch/LaunchWindow.tsx | 9 ++- src/components/video-editor/VideoEditor.tsx | 10 ++- src/lib/userPreferences.test.ts | 66 ++++++++++++++++++- src/lib/userPreferences.ts | 16 +++++ src/native/client.ts | 3 +- src/native/contracts.ts | 6 +- 11 files changed, 139 insertions(+), 18 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 0c6eb8b19..905e5581e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -229,7 +229,7 @@ interface Window { canceled?: boolean; error?: string; }>; - loadProjectFile: () => Promise<{ + loadProjectFile: (projectFolder?: string) => Promise<{ success: boolean; path?: string; project?: unknown; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 2c01ca28b..05cc72b81 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 { + async function loadProjectFile(projectFolder?: string): Promise { 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"), diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 7f7b24b51..9feeb2ede 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -25,7 +25,7 @@ export interface NativeBridgeContext { suggestedName?: string, existingProjectPath?: string, ) => Promise; - loadProjectFile: () => Promise; + loadProjectFile: (projectFolder?: string) => Promise; loadCurrentProjectFile: () => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; 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, diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 965b4fb70..216801a96 100644 --- a/electron/native-bridge/services/projectService.ts +++ b/electron/native-bridge/services/projectService.ts @@ -14,7 +14,7 @@ interface ProjectServiceOptions { suggestedName?: string, existingProjectPath?: string, ) => Promise; - loadProjectFile: () => Promise; + loadProjectFile: (projectFolder?: string) => Promise; loadCurrentProjectFile: () => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; 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; } diff --git a/electron/preload.ts b/electron/preload.ts index 60916ffe0..677f73008 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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"); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 570ec2809..a3e274f7f 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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(); }; diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b91b7a053..be568f621 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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]); diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index 5ba9fceb1..154f84c7d 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 28a45068e..24b93dc70 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -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 | 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. diff --git a/src/native/client.ts b/src/native/client.ts index 3f53ce483..b1e6d78f6 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -84,10 +84,11 @@ export const nativeBridgeClient = { existingProjectPath, }, }), - loadProjectFile: () => + loadProjectFile: (projectFolder?: string) => requireNativeBridgeData({ domain: "project", action: "loadProjectFile", + payload: { projectFolder }, }), loadCurrentProjectFile: () => requireNativeBridgeData({ diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 6836095ac..273cf1edd 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -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; } | { From f5ad566534a90af5c455614954cb1c08abfa8c06 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Garcia Date: Mon, 1 Jun 2026 08:21:01 +0200 Subject: [PATCH 2/2] fix: pass getProjectFolder to loadProjectFile in EditorEmptyState The Studio Dashboard's Load Project button was calling loadProjectFile() without the remembered folder, always defaulting to RECORDINGS_DIR. Also save projectFolder preference after a successful load from this path. --- src/components/video-editor/EditorEmptyState.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 511323abe..f8e4632d6 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -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]);