Skip to content
158 changes: 136 additions & 22 deletions apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
materializeEmbeddableVectorLayers,
} from "@geolibre/plugins";
import type { FeatureCollection } from "geojson";
import { type FormEvent, useRef, useState } from "react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createAppAPI, getPluginManager } from "./usePlugins";
import { pluginManifestUrlsForIds } from "../lib/external-plugins";
Expand Down Expand Up @@ -44,6 +44,13 @@ import { resolveShareBaseUrl } from "../lib/share-geolibre";
import { shareAuthorizedFetch } from "../lib/share-gallery";
import { normalizeProjectUrl } from "../lib/urls";
import { recordExplicitProjectSave } from "../lib/project-history-session";
import {
rememberProjectSaveChoices,
reusableCredentialChoice,
reusableVectorDataChoice,
saveChoicesForProject,
type ProjectSaveChoices,
} from "../lib/project-save-choices";
import { resolveProjectXyzLayers } from "../lib/xyz-url";
import {
importQgisProject,
Expand All @@ -56,6 +63,8 @@ import type { MapControllerRef } from "../components/layout/toolbar/constants";
/** A pending "strip credentials before saving?" prompt. */
export interface CredentialStripPrompt {
count: number;
/** Project generation that opened the prompt. */
projectGeneration: number;
resolve: (choice: "strip" | "keep" | "cancel") => void;
}

Expand Down Expand Up @@ -103,6 +112,8 @@ export interface EmbedVectorDataPrompt {
* described differently than on the web (where it discards the data).
*/
desktop: boolean;
/** Project generation that opened the prompt. */
projectGeneration: number;
resolve: (choice: "embed" | "noembed" | "cancel") => void;
}

Expand All @@ -113,6 +124,8 @@ export interface EmbedVectorDataPrompt {
* same component serves both.
*/
export interface SaveNamePrompt {
/** Project generation that opened the prompt. */
projectGeneration: number;
resolve: (name: string | null) => void;
/** Dialog title. */
title: string;
Expand Down Expand Up @@ -239,6 +252,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const rememberRecentProject = useAppStore((s) => s.rememberRecentProject);
const forgetRecentProject = useAppStore((s) => s.forgetRecentProject);
const markSaved = useAppStore((s) => s.markSaved);
const projectGeneration = useAppStore((s) => s.projectGeneration);

const [actionError, setActionError] = useState<string | null>(null);
const [qgisImportWarnings, setQgisImportWarnings] = useState<QgisProjectImportWarning[] | null>(
Expand All @@ -264,11 +278,34 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
// Separate from projectUrlAbortRef so a gallery open and an Open-from-URL
// submit can't abort each other's in-flight fetch.
const shareUrlAbortRef = useRef<AbortController | null>(null);
// Retain explicit, non-cancel save decisions for this project only. The
// generation check clears them synchronously when newProject/loadProject
// switches the store, including before React has rendered the new project.
const saveChoicesRef = useRef<ProjectSaveChoices | null>(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);

// A project can be replaced by an external open action while a modal save
// prompt is visible. Cancel the stale promise immediately so its dialog does
// not cover the replacement project and its save guard is released.
useEffect(() => {
if (credentialStripPrompt && credentialStripPrompt.projectGeneration !== projectGeneration) {
credentialStripPrompt.resolve("cancel");
setCredentialStripPrompt(null);
}
if (embedVectorDataPrompt && embedVectorDataPrompt.projectGeneration !== projectGeneration) {
embedVectorDataPrompt.resolve("cancel");
setEmbedVectorDataPrompt(null);
}
if (saveNamePrompt && saveNamePrompt.projectGeneration !== projectGeneration) {
saveNamePrompt.resolve(null);
setSaveNamePrompt(null);
setSaveNameInput("");
}
}, [credentialStripPrompt, embedVectorDataPrompt, projectGeneration, saveNamePrompt]);
Comment thread
giswqs marked this conversation as resolved.
Outdated

const handleOpenFromFile = async () => {
const result = await openProjectFile();
if (result) {
Expand Down Expand Up @@ -724,9 +761,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
// Ask whether to strip credentials (environment variables, geocoder keys,
// layer tokens) before writing the file. The promise resolves when the user
// picks an option in the dialog.
const askStripCredentials = (count: number) =>
const askStripCredentials = (count: number, promptProjectGeneration: number) =>
new Promise<"strip" | "keep" | "cancel">((resolve) => {
setCredentialStripPrompt({ count, resolve });
setCredentialStripPrompt({ count, projectGeneration: promptProjectGeneration, resolve });
});

const resolveCredentialStripPrompt = (choice: "strip" | "keep" | "cancel") => {
Expand All @@ -737,9 +774,20 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {

// Ask whether to embed local vector layers' data in the saved file. Resolves
// when the user picks an option in the dialog.
const askEmbedVectorData = (count: number, bytes: number, desktop: boolean) =>
const askEmbedVectorData = (
count: number,
bytes: number,
desktop: boolean,
promptProjectGeneration: number,
) =>
new Promise<"embed" | "noembed" | "cancel">((resolve) => {
setEmbedVectorDataPrompt({ count, bytes, desktop, resolve });
setEmbedVectorDataPrompt({
count,
bytes,
desktop,
projectGeneration: promptProjectGeneration,
resolve,
});
});

const resolveEmbedVectorDataPrompt = (choice: "embed" | "noembed" | "cancel") => {
Expand Down Expand Up @@ -810,13 +858,39 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const resolveLayersForSave = async (): Promise<{ layers?: GeoLibreLayer[] } | "cancel"> => {
const state = useAppStore.getState();
const embeddable = await materializeEmbeddableVectorLayers(state.layers);
if (useAppStore.getState().projectGeneration !== state.projectGeneration) return "cancel";
const localFileLayers = isTauri() ? state.layers.filter(isReloadableLocalFileLayer) : [];
if (embeddable.size === 0 && localFileLayers.length === 0) return {};

const count = embeddable.size + localFileLayers.length;
const bytes = estimateEmbedBytes(state.layers, embeddable);
const choice = await askEmbedVectorData(count, bytes, isTauri());
const remembered = saveChoicesForProject(saveChoicesRef.current, state.projectGeneration);
saveChoicesRef.current = remembered;
// A remembered Embed choice stays silent until the project first crosses
// the large-data warning threshold. That material risk deserves one fresh
// confirmation even though the ordinary per-project choice is remembered.
const rememberedVectorChoice = reusableVectorDataChoice(
remembered,
bytes >= LARGE_EMBED_WARNING_BYTES,
);
const choice =
rememberedVectorChoice ??
(await askEmbedVectorData(count, bytes, isTauri(), state.projectGeneration));
Comment thread
giswqs marked this conversation as resolved.
if (choice === "cancel") return "cancel";
// A project can be opened while a prompt is visible. Do not apply that
// prompt's answer to the replacement project or continue saving stale data.
if (useAppStore.getState().projectGeneration !== state.projectGeneration) return "cancel";
Comment thread
giswqs marked this conversation as resolved.
saveChoicesRef.current = rememberProjectSaveChoices(
saveChoicesRef.current,
state.projectGeneration,
{
vectorData: choice,
largeEmbedWarningAcknowledged:
choice === "embed" && bytes >= LARGE_EMBED_WARNING_BYTES
? true
: remembered.largeEmbedWarningAcknowledged,
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (choice === "embed") {
// Reuse the map already materialized for the size estimate.
Expand Down Expand Up @@ -870,10 +944,14 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
// the user can control. The caller supplies the dialog copy so the same prompt
// serves both project saves and HTML exports. Resolves with the name, or null
// if cancelled.
const askSaveName = (defaultName: string, labels: Omit<SaveNamePrompt, "resolve">) =>
const askSaveName = (
defaultName: string,
labels: Omit<SaveNamePrompt, "projectGeneration" | "resolve">,
promptProjectGeneration: number,
) =>
new Promise<string | null>((resolve) => {
setSaveNameInput(defaultName);
setSaveNamePrompt({ resolve, ...labels });
setSaveNamePrompt({ projectGeneration: promptProjectGeneration, resolve, ...labels });
});

const submitSaveNamePrompt = (event?: FormEvent<HTMLFormElement>) => {
Expand All @@ -890,10 +968,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
};

const runSaveProject = async (options?: { saveAs?: boolean }): Promise<boolean> => {
const saveProjectGeneration = useAppStore.getState().projectGeneration;
// Offer to embed local vector data (or, on desktop, save file references)
// first, so the serialized content below reflects the user's choice.
const layersForSave = await resolveLayersForSave();
if (layersForSave === "cancel") return false;
if (
layersForSave === "cancel" ||
useAppStore.getState().projectGeneration !== saveProjectGeneration
) {
return false;
}
const { project, defaultProjectName, projectPath } = buildCurrentProject(
undefined,
layersForSave.layers,
Expand All @@ -905,8 +989,22 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const projectToEgress = excludeHiddenFieldsFromProject(project);
const redacted = redactProjectCredentials(projectToEgress);
if (redacted.redactedPaths.length > 0) {
const choice = await askStripCredentials(redacted.redactedCount);
const remembered = saveChoicesForProject(saveChoicesRef.current, saveProjectGeneration);
saveChoicesRef.current = remembered;
const choice =
reusableCredentialChoice(remembered, redacted.redactedCount) ??
(await askStripCredentials(redacted.redactedCount, saveProjectGeneration));
if (choice === "cancel") return false;
if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false;
saveChoicesRef.current = rememberProjectSaveChoices(
saveChoicesRef.current,
saveProjectGeneration,
{
credentials: choice,
keptCredentialCount:
choice === "keep" ? redacted.redactedCount : remembered.keptCredentialCount,
},
);
contentToSave = serializeForSave(choice === "strip" ? redacted.project : projectToEgress);
} else {
contentToSave = serializeForSave(projectToEgress);
Expand All @@ -923,15 +1021,20 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const promptForName =
browserSaveFallsBackToDownload() && (options?.saveAs === true || !existingLocalPath);
if (promptForName) {
const chosen = await askSaveName(saveName, {
title: t("toolbar.item.saveProjectAsTitle"),
description: t("toolbar.item.saveProjectAsDesc"),
label: t("toolbar.item.saveProjectFileName"),
placeholder: t("toolbar.item.saveProjectFileNamePlaceholder"),
});
const chosen = await askSaveName(
saveName,
{
title: t("toolbar.item.saveProjectAsTitle"),
description: t("toolbar.item.saveProjectAsDesc"),
label: t("toolbar.item.saveProjectFileName"),
placeholder: t("toolbar.item.saveProjectFileNamePlaceholder"),
},
saveProjectGeneration,
);
if (chosen === null) return false;
saveName = ensureProjectFileName(chosen);
}
if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false;
let path: string | null;
try {
path =
Expand All @@ -949,6 +1052,10 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
return false;
}
if (!path) return false;
// A native picker can remain open while another project arrives through an
// external action. The old project may have been written successfully, but
// never attach its path or saved state to the replacement project.
if (useAppStore.getState().projectGeneration !== saveProjectGeneration) return false;
setProjectPath(path);
rememberRecentProject({
path,
Expand Down Expand Up @@ -980,6 +1087,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
if (isSavingRef.current) return false;
isSavingRef.current = true;
try {
const exportProjectGeneration = useAppStore.getState().projectGeneration;
// Derive the default file name from the project name in the store first,
// without materializing embedded data, so the prompt can appear right away
// and a cancel discards no work. This snapshot is passed to
Expand All @@ -999,12 +1107,16 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
// saveTextFileWithFallback below instead.
let defaultName = `${slug}.html`;
if (browserSaveFallsBackToDownload()) {
const chosen = await askSaveName(defaultName, {
title: t("toolbar.item.exportHtmlAsTitle"),
description: t("toolbar.item.exportHtmlAsDesc"),
label: t("toolbar.item.exportHtmlFileName"),
placeholder: t("toolbar.item.exportHtmlFileNamePlaceholder"),
});
const chosen = await askSaveName(
defaultName,
{
title: t("toolbar.item.exportHtmlAsTitle"),
description: t("toolbar.item.exportHtmlAsDesc"),
label: t("toolbar.item.exportHtmlFileName"),
placeholder: t("toolbar.item.exportHtmlFileNamePlaceholder"),
},
exportProjectGeneration,
);
if (chosen === null) return false;
defaultName = ensureHtmlFileName(chosen, slug);
}
Expand All @@ -1015,6 +1127,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
// serve no purpose in a static viewer and are removed inside
// buildProjectHtml, which runs the central redaction pass.
const { project, defaultProjectName } = await buildEmbeddedProject(projectName);
if (useAppStore.getState().projectGeneration !== exportProjectGeneration) return false;
Comment thread
giswqs marked this conversation as resolved.
const html = buildProjectHtml({
project,
title: defaultProjectName,
Expand All @@ -1032,6 +1145,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
],
mimeType: "text/html",
});
if (useAppStore.getState().projectGeneration !== exportProjectGeneration) return false;
return savedPath !== null;
} catch (error) {
setActionError(
Expand Down
Loading
Loading