Skip to content

Commit f1e7834

Browse files
Fix startup project projection initialization (#1962)
* Fix startup project projection initialization * Add default startup projection setting * style: auto-format (ruff + oxfmt) [pre-commit.ci] * Offer the startup projection setting in the browser build - Ungate the Settings → Startup section for non-Tauri builds and add a Startup Settings entry to the settings dropdown. Only the three project restoration modes are desktop-only (the browser has no persistent local file to reopen); the empty-workspace projection preference applies everywhere, so hide the mode radios rather than the whole section. - `useStartupProject` no longer bails out early on the web: it resolves a startup path only under Tauri, but still applies the configured empty-workspace projection before mounting the shell. - Persist a GlobeControl toggle from a click on the control itself instead of MapLibre's `projectiontransition` event. Style initialization and project reconciliation emit that event too, so a stale one could overwrite the projection of a project that had just loaded. - Update the Startup docs to match. * Address review feedback - Bound the startup restore gate. `openRecentProjectFile` and the XYZ probes have no deadline of their own, so a startup project whose tile host black-holes the connection left the window blank forever with no way to reach Settings. A 10s watchdog now mounts the shell over the default workspace; the restore keeps running and can still land, guarded by the existing `projectGeneration`/`isDirty` check. - Render a spinner and "Opening your startup project…" while the gate is up, instead of nothing at all, so a slow restore does not read as a hang. - Extract `hasExplicitLaunchPayload()` so the deep-link/`?data=` precedence check has one definition instead of a verbatim copy in both the `restoring` initializer and the effect, which could silently drift. - Preserve `globeByDefault` when a pinned startup project turns out to be missing. Resetting the whole block to `DEFAULT_STARTUP_SETTINGS` flipped a saved Mercator preference back to globe on the next launch; only the project selection should be cleared. - Refresh the stale "the shell is interactive throughout" comment: the shell is now unmounted until the gate expires. - Backfill the required `globeByDefault` field on every `StartupSettings` literal in tests/startup-project-settings.test.ts and the shared fixture in tests/startup-project-snapshot.test.ts. * Address CodeRabbit review feedback - Guard the failure path's `openDefaultWorkspace()` with the same `projectGeneration`/`isDirty` ownership test the success path already uses. Once the restore gate added in 3b7a26d expires the shell is live, so a restore that fails after that could reset the projection of a workspace the user had since opened or edited, and show a banner claiming the default workspace was opened for them. The `forgetRecentProject` and stale-preference cleanups stay unconditional: a project file that is gone stays gone whoever owns the workspace now. * Address Claude review feedback - Extract the GlobeControl class-name mirror into `packages/map/src/globe-control-toggle.ts` and pin it with `tests/globe-control-toggle.test.ts`, which builds a real `maplibre-gl` `GlobeControl` and asserts the selector still matches its button in both projections. These classes are internal and unexported, so a rename would otherwise have stopped persisting the user's projection with no build error. Added to CLAUDE.md's recheck-on-bump list next to the other internal-class mirrors, and noted in the handler why reading `getProjection()` on the bubbled click is not stale (the control's own listener runs first and `setProjection` is synchronous). - Stop showing the restore spinner on launches with nothing to restore. `restoring` started `true` for every build, so the browser and the Jupyter embed unmounted the shell and flashed "Opening your startup project…" on every load even though they never restore a project. The decision now lives in one pure `planStartup()` in `lib/startup-project.ts` (payload / restore / default), which the render-time gate and the restore effect both call, so they cannot drift. The empty-workspace projection is seeded during the first render rather than from an effect, because MapCanvas creates the map in its own mount effect, which runs before this component's — so the shell no longer has to be unmounted to avoid the globe flash. Desktop launches in default mode also mount immediately now. - `planStartup` is covered in `tests/startup-project-settings.test.ts`: payload precedence, desktop restore, the non-desktop case honoring the projection but never restoring, and the empty-workspace fallback. - Note explicitly why the gate-timeout callback omits the `projectGeneration`/`isDirty` ownership check the other two paths have: the shell is unmounted while the gate is up, so there is nothing to overwrite. Rendering anything interactive behind the spinner would break that invariant. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1a35006 commit f1e7834

14 files changed

Lines changed: 519 additions & 64 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); conf
131131
- `MAX_VECTOR_PMTILES_ZOOM` (`packages/processing/src/wasm-convert.ts`) mirrors the deepest zoom `vector_to_pmtiles` accepts (18 — past it the tool exits with `validation error: max_zoom must be <= 18`). The cap lives inside the WASM binary and is not exported, so whenever `geolibre-wasm` is bumped — including Dependabot PRs — re-check it. If it drifts, the browser's Vector to PMTiles either refuses a zoom the tiler would now accept, or accepts one it will reject after the user has waited. Note this is **not** the sidecar's cap: freestiler allows 24 (`MAX_PMTILES_ZOOM` in `ConversionDialog.tsx`, mirroring `backend/geolibre_server/geolibre_server/app/conversion.py`), and the dialog validates against whichever engine is about to run. `tests/wasm-convert.test.ts` ("accepts the documented maximum zoom and rejects one deeper") fails in CI if the mirror drifts, so running the frontend suite after a bump is enough to catch it.
132132
- `MAX_VECTOR_BYTES` (`packages/plugins/src/plugins/remote-file-formats.ts`) mirrors `MAX_REMOTE_FILE_BYTES`, an **internal, unexported** constant in `maplibre-gl-vector` (2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported, so whenever `maplibre-gl-vector` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check `src/lib/utils/remote.ts` in that package and update the mirror if it moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face) silently block GeoParquet the engine could now open, or offer an Add that is certain to fail. Updating the constant is enough: the limit the user is shown is rendered from it, not written into the copy. `remote-file-formats.ts` is the **single** home for this and the other format/reader/size rules those panels share — a per-panel copy would miss this check, so add new browse panels against that module rather than duplicating it (`source-coop-api.ts` re-exports it under its own names for compatibility).
133133
- `MAP_PANEL_SELECTOR` (`apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx`) mirrors the **rendered** control class names from `maplibre-gl-components``maplibre-gl-html-control`, `maplibre-gl-legend`, `maplibre-gl-colorbar` — so the Record Video "Include map panels" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately **not** the `*-gui-control` authoring editors. The classes are internal and unexported, so whenever `maplibre-gl-components` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check them against the rendered controls and update the selector if they moved. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error.
134+
- `GLOBE_CONTROL_TOGGLE_SELECTOR` (`packages/map/src/globe-control-toggle.ts`) mirrors the class names MapLibre's own `GlobeControl` puts on its toggle button — `maplibregl-ctrl-globe` and `maplibregl-ctrl-globe-enabled`, swapped on every projection change. `MapCanvas` persists a projection change from a **click** on that button rather than from the `projectiontransition` event, because style initialization and project reconciliation emit that event too and a stale one overwrites the projection of a project that has just loaded. The classes are internal and unexported, so whenever `maplibre-gl` is bumped (including Dependabot PRs) run the frontend suite — `tests/globe-control-toggle.test.ts` builds a real `GlobeControl` and fails if the mirror stops matching. Without that check a renamed class silently stops persisting the user's projection, with no build error.
134135
- `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the "enforces an expected result type" test in `tests/expressions.test.ts` fails if the shape stops being honored.
135136
- `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter *name*, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error.
136137
- UI strings are translatable via **react-i18next**; catalogs live in `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings; a `?locale`/`?lang` query param sets the embed language. The UI mirrors for right-to-left locales (Arabic), so style new components with Tailwind's logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…), not the physical `ml-`/`left-` forms. See `docs/i18n.md`.

apps/geolibre-desktop/src/App.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DirectionProvider } from "@geolibre/ui";
2+
import { Loader2 } from "lucide-react";
23
import { useTranslation } from "react-i18next";
34
import { useCallback, useState } from "react";
45
import { DesktopShell } from "./components/layout/DesktopShell";
@@ -29,7 +30,7 @@ export default function App() {
2930
useLastBasemapPersistence();
3031
// Re-renders on language change, so Radix primitives (menus, sliders, tabs)
3132
// pick up the right-to-left direction together with the document `dir`.
32-
const { i18n } = useTranslation();
33+
const { i18n, t } = useTranslation();
3334
const layoutOptions = useLayoutOptions();
3435
const { themeMode, toggleThemeMode } = useThemeMode();
3536
// `onMapReady` fires again on every basemap swap (MapCanvas re-emits
@@ -48,7 +49,8 @@ export default function App() {
4849
useDesktopSettingsPersistence();
4950
useThemeScheme();
5051
useRecentProjectsPersistence();
51-
const startupProjectWarning = useStartupProject();
52+
const { warning: startupProjectWarning, restoring: restoringStartupProject } =
53+
useStartupProject();
5254
useStyleLibraryPersistence();
5355
useLayerLibraryPersistence();
5456
useTemplateLibraryPersistence();
@@ -58,16 +60,32 @@ export default function App() {
5860
useWhiteboxToolUrl();
5961
return (
6062
<DirectionProvider dir={languageDirection(i18n.language)}>
61-
<DesktopShell
62-
layoutOptions={layoutOptions}
63-
projectUrlLoadState={projectUrlLoadState}
64-
dataUrlLoadState={dataUrlLoadState}
65-
mapAppAPI={mapAppAPI}
66-
themeMode={themeMode}
67-
onToggleThemeMode={toggleThemeMode}
68-
onMapReady={handleMapReady}
69-
/>
70-
<OnboardingDialog open={showOnboarding} onClose={dismissOnboarding} />
63+
{restoringStartupProject ? (
64+
// The shell is deliberately unmounted while the startup project loads
65+
// (see `useStartupProject`), so say what the window is waiting on rather
66+
// than leaving it blank. `useStartupProject` bounds this state, so it
67+
// cannot become a permanent splash screen.
68+
<div
69+
role="status"
70+
className="flex h-screen w-screen items-center justify-center gap-3 bg-background text-sm text-muted-foreground"
71+
>
72+
<Loader2 className="h-4 w-4 animate-spin" />
73+
{t("settings.startup.restoring")}
74+
</div>
75+
) : (
76+
<>
77+
<DesktopShell
78+
layoutOptions={layoutOptions}
79+
projectUrlLoadState={projectUrlLoadState}
80+
dataUrlLoadState={dataUrlLoadState}
81+
mapAppAPI={mapAppAPI}
82+
themeMode={themeMode}
83+
onToggleThemeMode={toggleThemeMode}
84+
onMapReady={handleMapReady}
85+
/>
86+
<OnboardingDialog open={showOnboarding} onClose={dismissOnboarding} />
87+
</>
88+
)}
7189
<UpdateNotificationModal
7290
pending={pendingUpdate}
7391
onRemindLater={remindLater}

apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,6 @@ export function SettingsDialog({
456456
// The Microsoft Store build has no in-app update flow to configure (policy
457457
// 10.2.5), so its settings section is dropped entirely.
458458
if (id === "updates" && IS_STORE_BUILD) return false;
459-
if (id === "startup" && !isTauri()) return false;
460459
const gate = SECTION_GATE[id];
461460
return gate ? showSettingsItem(gate) : true;
462461
};
@@ -1504,6 +1503,17 @@ export function SettingsDialog({
15041503
{t("settings.menu.updates")}
15051504
</DropdownMenuItem>
15061505
)}
1506+
{isSectionVisible("startup") && (
1507+
<DropdownMenuItem
1508+
onSelect={() => {
1509+
setSection("startup");
1510+
setOpen(true);
1511+
}}
1512+
>
1513+
<FolderOpen className="me-2 h-3.5 w-3.5" />
1514+
{t("settings.menu.startupSettings")}
1515+
</DropdownMenuItem>
1516+
)}
15071517
{/* The Mac App Store build has no plugin marketplace (external
15081518
plugin installs are not allowed there), so its entry point is
15091519
dropped; composed with the profile gate like the Store build's
@@ -2542,7 +2552,7 @@ export function SettingsDialog({
25422552
{t("settings.startup.description")}
25432553
</p>
25442554
</div>
2545-
<div className="space-y-2">
2555+
<div className={isTauri() ? "space-y-2" : "hidden"}>
25462556
{(["default", "last"] as const).map((mode) => (
25472557
<label
25482558
key={mode}
@@ -2590,6 +2600,22 @@ export function SettingsDialog({
25902600
</Button>
25912601
</label>
25922602
</div>
2603+
<label className="flex items-start gap-3 rounded-md border p-3 text-sm">
2604+
<input
2605+
className="mt-0.5 h-4 w-4"
2606+
type="checkbox"
2607+
checked={draftDesktopSettings.startup.globeByDefault}
2608+
onChange={(event) =>
2609+
updateDraftStartupSettings({ globeByDefault: event.target.checked })
2610+
}
2611+
/>
2612+
<span className="space-y-1">
2613+
<span className="block">{t("settings.startup.globeByDefault")}</span>
2614+
<span className="block text-xs text-muted-foreground">
2615+
{t("settings.startup.globeByDefaultHint")}
2616+
</span>
2617+
</span>
2618+
</label>
25932619
</div>
25942620
) : null}
25952621
{effectiveSection === "updates" ? (

apps/geolibre-desktop/src/hooks/useDesktopSettings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export interface StartupSettings {
9393
mode: StartupProjectMode;
9494
projectPath: string | null;
9595
projectName: string | null;
96+
/** Projection used when startup does not restore or receive a project. */
97+
globeByDefault: boolean;
9698
}
9799

98100
export interface ThemeSettings {
@@ -200,6 +202,7 @@ export const DEFAULT_STARTUP_SETTINGS: StartupSettings = {
200202
mode: "default",
201203
projectPath: null,
202204
projectName: null,
205+
globeByDefault: true,
203206
};
204207

205208
export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
@@ -279,6 +282,7 @@ function normalizeStartupSettings(startup: unknown): StartupSettings {
279282
mode: mode === "specific" && !projectPath ? "default" : mode,
280283
projectPath,
281284
projectName,
285+
globeByDefault: typeof candidate.globeByDefault === "boolean" ? candidate.globeByDefault : true,
282286
};
283287
}
284288

0 commit comments

Comments
 (0)