From 866ee27e202414a098c5b276c5aa6e7529b6ccdc Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 17 Jun 2026 21:19:44 -0400 Subject: [PATCH 1/2] fix(project): prompt for a file name on Save As in browsers without the save picker Firefox and Safari lack the File System Access API, so Save and Save As both fell back to an anchor download under a fixed default name, leaving users unable to name the project file. Prompt for a name in that fallback so Save As (and a first Save) honor the user's choice. --- .../layout/toolbar/ProjectFileDialogs.tsx | 44 ++++++++++++ .../src/hooks/useProjectFileActions.ts | 69 ++++++++++++++++++- .../geolibre-desktop/src/i18n/locales/en.json | 4 ++ apps/geolibre-desktop/src/lib/tauri-io.ts | 18 +++++ 4 files changed, 134 insertions(+), 1 deletion(-) diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx index bf83532ae..a6f139452 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx @@ -85,6 +85,50 @@ 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..60e109452 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,6 +77,10 @@ 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); @@ -222,6 +251,26 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { setEnvStripPrompt(null); }; + // 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); + }; + + const cancelSaveNamePrompt = () => { + saveNamePrompt?.resolve(null); + setSaveNamePrompt(null); + }; + const saveProject = async (options?: { saveAs?: boolean; }): Promise => { @@ -247,6 +296,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 +316,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); @@ -305,6 +367,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, From e69756e924268319a32476711981da30e29c4607 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 17 Jun 2026 21:30:25 -0400 Subject: [PATCH 2/2] Address Claude review feedback - Serialize saves with an in-flight ref guard so a second save started while a prompt dialog is open cannot clobber the pending prompt and strand the first call's unresolved promise. - Disable the Save button in the name dialog when the input is blank, so a whitespace-only name can no longer silently fall back to the default. - Reset saveNameInput to "" when the name dialog closes, making the reset explicit rather than relying on the next open to overwrite it. - Mention the save-name dialog in the ProjectFileDialogs docstring. --- .../layout/toolbar/ProjectFileDialogs.tsx | 9 ++++++-- .../src/hooks/useProjectFileActions.ts | 21 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx index a6f139452..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(); @@ -124,7 +124,12 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) { > {t("common.cancel")} - + diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 60e109452..0df7691da 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -83,6 +83,10 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { 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(); @@ -264,14 +268,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { event?.preventDefault(); saveNamePrompt?.resolve(saveNameInput); setSaveNamePrompt(null); + setSaveNameInput(""); }; const cancelSaveNamePrompt = () => { saveNamePrompt?.resolve(null); setSaveNamePrompt(null); + setSaveNameInput(""); }; - const saveProject = async (options?: { + const runSaveProject = async (options?: { saveAs?: boolean; }): Promise => { const { project, defaultProjectName, content, projectPath } = @@ -338,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 });