Skip to content
32 changes: 30 additions & 2 deletions apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ 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,
saveChoicesForProject,
type ProjectSaveChoices,
} from "../lib/project-save-choices";
import { resolveProjectXyzLayers } from "../lib/xyz-url";
import {
importQgisProject,
Expand Down Expand Up @@ -264,6 +269,10 @@ 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.
Expand Down Expand Up @@ -815,8 +824,18 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {

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;
const choice = remembered.vectorData ?? (await askEmbedVectorData(count, bytes, isTauri()));
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 },
);
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 @@ -905,8 +924,17 @@ 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 projectGeneration = useAppStore.getState().projectGeneration;
const remembered = saveChoicesForProject(saveChoicesRef.current, projectGeneration);
saveChoicesRef.current = remembered;
const choice = remembered.credentials ?? (await askStripCredentials(redacted.redactedCount));
if (choice === "cancel") return false;
if (useAppStore.getState().projectGeneration !== projectGeneration) return false;
saveChoicesRef.current = rememberProjectSaveChoices(
saveChoicesRef.current,
projectGeneration,
{ credentials: choice },
);
contentToSave = serializeForSave(choice === "strip" ? redacted.project : projectToEgress);
} else {
contentToSave = serializeForSave(projectToEgress);
Expand Down
49 changes: 49 additions & 0 deletions apps/geolibre-desktop/src/lib/project-save-choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** How credentials should be handled when the current project is saved. */
export type CredentialSaveChoice = "strip" | "keep";

/** How local vector data should be handled when the current project is saved. */
export type VectorDataSaveChoice = "embed" | "noembed";

/** Save choices remembered for one loaded project during the current session. */
export interface ProjectSaveChoices {
projectGeneration: number;
credentials?: CredentialSaveChoice;
vectorData?: VectorDataSaveChoice;
}

/**
* Returns remembered choices only when they belong to the current project.
*
* The store increments `projectGeneration` for every new or loaded project, so
* this keeps potentially sensitive save decisions from leaking into another
* project while allowing repeated saves of the same project to stay silent.
*
* @param remembered - Choices retained by the project-file hook, if any.
* @param projectGeneration - Generation of the currently loaded project.
* @returns Existing choices for this project, or an empty choice set.
*/
export function saveChoicesForProject(
remembered: ProjectSaveChoices | null,
projectGeneration: number,
): ProjectSaveChoices {
return remembered?.projectGeneration === projectGeneration ? remembered : { projectGeneration };
}

/**
* Remembers one or more save choices for the current project.
*
* @param remembered - Choices retained by the project-file hook, if any.
* @param projectGeneration - Generation of the currently loaded project.
* @param choices - New choices to retain.
* @returns Updated choices scoped to the supplied project generation.
*/
export function rememberProjectSaveChoices(
remembered: ProjectSaveChoices | null,
projectGeneration: number,
choices: Partial<Omit<ProjectSaveChoices, "projectGeneration">>,
): ProjectSaveChoices {
return {
...saveChoicesForProject(remembered, projectGeneration),
...choices,
};
}
35 changes: 35 additions & 0 deletions tests/project-save-choices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
rememberProjectSaveChoices,
saveChoicesForProject,
} from "../apps/geolibre-desktop/src/lib/project-save-choices";

describe("project save choices", () => {
it("remembers credential and vector-data choices for the current project", () => {
const credentials = rememberProjectSaveChoices(null, 4, { credentials: "strip" });
const complete = rememberProjectSaveChoices(credentials, 4, { vectorData: "embed" });

assert.deepEqual(saveChoicesForProject(complete, 4), {
projectGeneration: 4,
credentials: "strip",
vectorData: "embed",
});
});

it("clears remembered choices when the project generation changes", () => {
const remembered = rememberProjectSaveChoices(null, 4, {
credentials: "keep",
vectorData: "noembed",
});

assert.deepEqual(saveChoicesForProject(remembered, 5), { projectGeneration: 5 });
});

it("does not restore choices from a previously loaded project", () => {
const firstProject = rememberProjectSaveChoices(null, 4, { credentials: "strip" });
const secondProject = saveChoicesForProject(firstProject, 5);

assert.deepEqual(saveChoicesForProject(secondProject, 4), { projectGeneration: 4 });
});
});
Loading