diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index ac29d45af..695d78292 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 15a6539a7..f2812d4f9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 { + 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 425f93e1a..2669300a9 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; loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; @@ -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, diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 9e96aa22d..0c363cc13 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; loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; @@ -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; } diff --git a/electron/preload.ts b/electron/preload.ts index a89d296ee..1a4626649 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); }, loadProjectFileFromPath: (filePath: string) => { return ipcRenderer.invoke("load-project-file-from-path", filePath); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index bba5f494c..e24d6898a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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"; 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]); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 05034632e..9f1bf1522 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, @@ -719,7 +720,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; @@ -736,6 +737,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 87ed259f2..8a64295c5 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -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(); + 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(); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 128eb73b8..730236016 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; /** 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. diff --git a/src/native/client.ts b/src/native/client.ts index 9ff60d357..8d15c324d 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 77afa6f48..123447f14 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; } | {