diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx index bf83532ae..e7569f4a0 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx @@ -15,7 +15,7 @@ interface ProjectFileDialogsProps { projectFiles: ReturnType; } -/** The project-file dialogs: Open-from-URL, the error dialog, and the env-var strip prompt. */ +/** The project-file dialogs: Open-from-URL, the error dialog, the save-name prompt, and the env-var strip prompt. */ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) { const { t } = useTranslation(); @@ -85,6 +85,55 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) { + { + if (!open) projectFiles.cancelSaveNamePrompt(); + }} + > + + + {t("toolbar.item.saveProjectAsTitle")} + + {t("toolbar.item.saveProjectAsDesc")} + + +
+
+ + + projectFiles.setSaveNameInput(event.target.value) + } + /> +
+
+ + +
+
+
+
{ diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 75d061fec..0df7691da 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { getPluginManager } from "./usePlugins"; import { useDesktopSettingsStore } from "./useDesktopSettings"; import { + browserSaveFallsBackToDownload, isHttpUrl, isTauri, openProjectFile, @@ -28,6 +29,30 @@ export interface EnvStripPrompt { resolve: (choice: "strip" | "keep" | "cancel") => void; } +/** + * A pending "name this project file" prompt, shown when Save As (or a first + * Save) runs in a browser that can only download under a fixed name. + */ +export interface SaveNamePrompt { + resolve: (name: string | null) => void; +} + +/** + * Ensure a user-entered project file name carries a recognized extension, + * defaulting to `.geolibre.json` when none is present so the downloaded file + * opens cleanly again later. Falls back to the default project name when blank. + * + * @param name - The raw file name the user typed. + * @returns A sanitized file name ending in a project extension. + */ +function ensureProjectFileName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return `${DEFAULT_PROJECT_NAME}.geolibre.json`; + return /\.(geolibre\.json|geolibre|json)$/i.test(trimmed) + ? trimmed + : `${trimmed}.geolibre.json`; +} + /** * Bundles every project file action (open from file/URL/recent, save, save as) * along with the related dialog state (Open-from-URL, env-var strip prompt, and @@ -52,8 +77,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const [envStripPrompt, setEnvStripPrompt] = useState( null, ); + const [saveNamePrompt, setSaveNamePrompt] = useState( + null, + ); + const [saveNameInput, setSaveNameInput] = useState(""); const projectUrlAbortRef = useRef(null); const recentAbortRef = useRef(null); + // Guards against overlapping saves: a second save started while a prompt + // dialog is open would overwrite the pending prompt and strand the first + // call's unresolved promise. + const isSavingRef = useRef(false); const handleOpenFromFile = async () => { const result = await openProjectFile(); @@ -222,7 +255,29 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { setEnvStripPrompt(null); }; - const saveProject = async (options?: { + // Ask the user to name the project file. Used only when saving falls back to + // a browser download (no File System Access picker), where the name is the + // only thing the user can control. Resolves with the name, or null if cancelled. + const askSaveName = (defaultName: string) => + new Promise((resolve) => { + setSaveNameInput(defaultName); + setSaveNamePrompt({ resolve }); + }); + + const submitSaveNamePrompt = (event?: FormEvent) => { + event?.preventDefault(); + saveNamePrompt?.resolve(saveNameInput); + setSaveNamePrompt(null); + setSaveNameInput(""); + }; + + const cancelSaveNamePrompt = () => { + saveNamePrompt?.resolve(null); + setSaveNamePrompt(null); + setSaveNameInput(""); + }; + + const runSaveProject = async (options?: { saveAs?: boolean; }): Promise => { const { project, defaultProjectName, content, projectPath } = @@ -247,6 +302,19 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // Save As fall back to the save dialog for them. const existingLocalPath = projectPath && !isHttpUrl(projectPath) ? projectPath : null; + // Browsers without the File System Access picker (Firefox, Safari) can only + // download under a fixed name, so Save As (and a first Save) would otherwise + // reuse a default name — exactly the bug users hit. Prompt for the name so + // they can choose it; later in-place Saves reuse the chosen name silently. + let saveName = `${defaultProjectName}.geolibre.json`; + const promptForName = + browserSaveFallsBackToDownload() && + (options?.saveAs === true || !existingLocalPath); + if (promptForName) { + const chosen = await askSaveName(saveName); + if (chosen === null) return false; + saveName = ensureProjectFileName(chosen); + } let path: string | null; try { path = @@ -254,7 +322,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { ? await saveProjectFileToPath(contentToSave, existingLocalPath) : await saveProjectFile( contentToSave, - existingLocalPath ?? `${defaultProjectName}.geolibre.json`, + promptForName ? saveName : (existingLocalPath ?? saveName), ); } catch (error) { console.error("Failed to save project", error); @@ -276,6 +344,19 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { return true; }; + // Serialize saves so overlapping invocations cannot clobber a pending prompt. + const saveProject = async (options?: { + saveAs?: boolean; + }): Promise => { + if (isSavingRef.current) return false; + isSavingRef.current = true; + try { + return await runSaveProject(options); + } finally { + isSavingRef.current = false; + } + }; + const handleSave = () => saveProject(); const handleSaveAs = () => saveProject({ saveAs: true }); @@ -305,6 +386,11 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { projectUrlLoading, envStripPrompt, resolveEnvStripPrompt, + saveNamePrompt, + saveNameInput, + setSaveNameInput, + submitSaveNamePrompt, + cancelSaveNamePrompt, handleOpenFromFile, handleOpenFromUrl, handleOpenRecent, diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index c566567b8..48141387a 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -834,6 +834,10 @@ "open": "Open", "somethingWentWrong": "Something went wrong", "dismiss": "Dismiss", + "saveProjectAsTitle": "Save project as", + "saveProjectAsDesc": "Choose a file name. The project downloads to your browser's downloads folder.", + "saveProjectFileName": "File name", + "saveProjectFileNamePlaceholder": "my-project.geolibre.json", "directionsNoticeTitle": "Directions uses a public routing server", "directionsNoticeDesc": "Turning on Directions sends the waypoints you place to the public OSRM demo server (router.project-osrm.org) to calculate routes — your coordinates leave your device for those requests. The demo server is rate-limited and supports driving only.", "reverseGeocode": "Reverse Geocode", diff --git a/apps/geolibre-desktop/src/lib/tauri-io.ts b/apps/geolibre-desktop/src/lib/tauri-io.ts index 77d1d550c..f9df202e5 100644 --- a/apps/geolibre-desktop/src/lib/tauri-io.ts +++ b/apps/geolibre-desktop/src/lib/tauri-io.ts @@ -628,6 +628,24 @@ async function openProjectFileBrowser(): Promise<{ }; } +/** + * Whether saving a project in the current environment would silently fall back + * to an anchor download under a fixed name — i.e. a browser (not Tauri) that + * lacks the File System Access save picker (`window.showSaveFilePicker`). + * Chromium browsers expose the picker and let the user name the file; Firefox + * and Safari do not, so callers prompt for a file name themselves before saving. + * + * @returns True only in a browser without the save picker; false under Tauri + * (which uses the native save dialog) or when the picker is available. + */ +export function browserSaveFallsBackToDownload(): boolean { + if (isTauri()) return false; + if (typeof window === "undefined") return false; + return ( + typeof (window as BrowserFilePickerWindow).showSaveFilePicker !== "function" + ); +} + async function saveProjectFileBrowser( content: string, defaultName?: string,