mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-08-29 03:08:30 +08:00
Merge pull request #669 from paulohenriquesg/feat/remember-last-project-folder
feat: Add projectFolder to user preferences
This commit is contained in:
Vendored
+1
-1
@@ -229,7 +229,7 @@ interface Window {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadProjectFile: () => Promise<{
|
||||
loadProjectFile: (projectFolder?: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -84,10 +84,11 @@ export const nativeBridgeClient = {
|
||||
existingProjectPath,
|
||||
},
|
||||
}),
|
||||
loadProjectFile: () =>
|
||||
loadProjectFile: (projectFolder?: string) =>
|
||||
requireNativeBridgeData<ProjectFileResult>({
|
||||
domain: "project",
|
||||
action: "loadProjectFile",
|
||||
payload: { projectFolder },
|
||||
}),
|
||||
loadCurrentProjectFile: () =>
|
||||
requireNativeBridgeData<ProjectFileResult>({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user