Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,50 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) {
</div>
</DialogContent>
</Dialog>
<Dialog
open={projectFiles.saveNamePrompt !== null}
Comment thread
giswqs marked this conversation as resolved.
onOpenChange={(open: boolean) => {
if (!open) projectFiles.cancelSaveNamePrompt();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("toolbar.item.saveProjectAsTitle")}</DialogTitle>
<DialogDescription>
{t("toolbar.item.saveProjectAsDesc")}
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={projectFiles.submitSaveNamePrompt}
>
<div className="space-y-2">
<Label htmlFor="save-project-name">
{t("toolbar.item.saveProjectFileName")}
</Label>
<Input
id="save-project-name"
autoFocus
placeholder={t("toolbar.item.saveProjectFileNamePlaceholder")}
value={projectFiles.saveNameInput}
onChange={(event) =>
projectFiles.setSaveNameInput(event.target.value)
}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => projectFiles.cancelSaveNamePrompt()}
>
{t("common.cancel")}
</Button>
<Button type="submit">{t("common.save")}</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog
open={projectFiles.envStripPrompt !== null}
onOpenChange={(open: boolean) => {
Expand Down
69 changes: 68 additions & 1 deletion apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { getPluginManager } from "./usePlugins";
import { useDesktopSettingsStore } from "./useDesktopSettings";
import {
browserSaveFallsBackToDownload,
isHttpUrl,
isTauri,
openProjectFile,
Expand All @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nits grouped:

No unit tests for ensureProjectFileName. The function has four distinct branches (blank input, .geolibre.json, .geolibre, no recognised extension). Other small utilities in this repo (e.g. the string-list helpers) are covered by tests/*.test.ts; a handful of assertions here would be low-cost and prevent accidental regex regressions.

setSaveNameInput on the public return surface. It is only consumed by ProjectFileDialogs for its controlled <Input>, which is fine. But exposing the raw setter means external callers can mutate saveNameInput while a saveNamePrompt promise is pending — the resolved value would silently diverge from what the user typed. Consider keeping the setter internal and wiring the onChange callback through a dedicated onSaveNameChange prop, matching how the URL-open input is handled.

const trimmed = name.trim();
if (!trimmed) return `${DEFAULT_PROJECT_NAME}.geolibre.json`;
return /\.(geolibre\.json|geolibre|json)$/i.test(trimmed)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex accepts a bare .json extension as sufficient, so a user who types report.json gets a file downloaded as report.json — valid JSON that re-imports fine, but the OS and the file-open dialog won't recognise it as a GeoLibre project (the picker only surfaces .geolibre / .geolibre.json by name). The placeholder already guides users toward the double-extension; tightening the regex to match would make the sanitisation enforce the same intent:

Suggested change
return /\.(geolibre\.json|geolibre|json)$/i.test(trimmed)
return /\.(geolibre\.json|geolibre)$/i.test(trimmed)
? trimmed
: `${trimmed}.geolibre.json`;

If .json should intentionally stay accepted (e.g. to round-trip files the open-dialog also accepts as .json), a brief note in the JSDoc would document that deliberately.

? trimmed
: `${trimmed}.geolibre.json`;
}
Comment thread
giswqs marked this conversation as resolved.

/**
* 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
Expand All @@ -52,6 +77,10 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const [envStripPrompt, setEnvStripPrompt] = useState<EnvStripPrompt | null>(
null,
);
const [saveNamePrompt, setSaveNamePrompt] = useState<SaveNamePrompt | null>(
null,
);
const [saveNameInput, setSaveNameInput] = useState("");
const projectUrlAbortRef = useRef<AbortController | null>(null);
const recentAbortRef = useRef<AbortController | null>(null);

Expand Down Expand Up @@ -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<string | null>((resolve) => {
setSaveNameInput(defaultName);
setSaveNamePrompt({ resolve });
});
Comment thread
giswqs marked this conversation as resolved.

const submitSaveNamePrompt = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
saveNamePrompt?.resolve(saveNameInput);
setSaveNamePrompt(null);
};

const cancelSaveNamePrompt = () => {
saveNamePrompt?.resolve(null);
setSaveNamePrompt(null);
};

const saveProject = async (options?: {
saveAs?: boolean;
}): Promise<boolean> => {
Expand All @@ -247,14 +296,27 @@ 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 =
!options?.saveAs && existingLocalPath
? await saveProjectFileToPath(contentToSave, existingLocalPath)
: await saveProjectFile(
contentToSave,
existingLocalPath ?? `${defaultProjectName}.geolibre.json`,
promptForName ? saveName : (existingLocalPath ?? saveName),
);
} catch (error) {
console.error("Failed to save project", error);
Expand Down Expand Up @@ -305,6 +367,11 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
projectUrlLoading,
envStripPrompt,
resolveEnvStripPrompt,
saveNamePrompt,
saveNameInput,
setSaveNameInput,
submitSaveNamePrompt,
cancelSaveNamePrompt,
handleOpenFromFile,
handleOpenFromUrl,
handleOpenRecent,
Expand Down
4 changes: 4 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions apps/geolibre-desktop/src/lib/tauri-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading