-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathSettingsDialog.tsx
More file actions
2533 lines (2455 loc) · 108 KB
/
Copy pathSettingsDialog.tsx
File metadata and controls
2533 lines (2455 loc) · 108 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import {
DEFAULT_PROJECT_PREFERENCES,
ELLIPSOIDS,
GEOCODING_PROVIDERS,
getGeocodingProvider,
normalizeGeocodingProviderId,
useAppStore,
type MapPreferences,
type MapProjection,
type MapScaleUnit,
type ProjectPreferences,
type RuntimeEnvironmentVariable,
} from "@geolibre/core";
import { closeRightPanel, collapseRightPanel, openRightPanel } from "@geolibre/plugins";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Input,
Label,
Select,
cn,
} from "@geolibre/ui";
import type { MapController } from "@geolibre/map";
import {
Bot,
Braces,
Check,
Crosshair,
DownloadCloud,
ExternalLink,
Eye,
EyeOff,
FolderCog,
FolderTree,
Languages,
Locate,
MapPinned,
LayoutPanelTop,
MessageSquare,
Moon,
Palette,
PanelLeft,
PanelRight,
Plus,
RotateCcw,
Settings,
SlidersHorizontal,
Sun,
Terminal,
Type,
Trash2,
TriangleAlert,
Puzzle,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
DEFAULT_DESKTOP_LAYOUT_SETTINGS,
DEFAULT_UI_PROFILE_SETTINGS,
DEFAULT_UPDATE_SETTINGS,
EXPERIENCE_LEVELS,
UPDATE_NOTIFICATION_LEVELS,
useDesktopSettingsStore,
type DesktopSettings,
type DesktopLayoutSettings,
type ExperienceLevel,
type UiProfileSettings,
type UpdateSettings,
} from "../../hooks/useDesktopSettings";
import { useLanguage } from "../../hooks/useLanguage";
import { BROWSER_PANEL_ID } from "../../hooks/useRegisterBrowserPanel";
import { COMMENTS_PANEL_ID } from "../../hooks/useRegisterCommentsPanel";
import { useRightPanelState } from "../../hooks/useRightPanels";
import type { ThemeMode } from "../../hooks/useThemeMode";
import { isTauri } from "../../lib/is-tauri";
import { THEME_SCHEMES, normalizeHexColor, type ThemeScheme } from "../../lib/theme-schemes";
import { IS_MAS_BUILD } from "../../lib/build-flags";
import { IS_STORE_BUILD, type UpdateNotificationLevel } from "../../lib/updates";
import {
DATA_SOURCE_CATALOG,
DATA_SOURCE_SECTION_LABEL_KEYS,
DATA_SOURCE_SECTION_ORDER,
INTERFACE_PROFILES,
MENU_ITEM_CATALOG,
MENU_ITEM_GROUPS,
TOP_LEVEL_MENUS,
activeInterfaceProfile,
isMenuItemVisible,
presetHiddenSets,
showsAdvancedNotices,
} from "../../lib/ui-profile";
import {
ASSISTANT_PROVIDER_IDS,
PROVIDER_LABELS,
scopeOsEnvToProject,
type AssistantProfile,
type AssistantProviderId,
type RuntimeEnv,
} from "../../lib/assistant/provider";
import { loadOsEnvVars, readOsEnv } from "../../lib/assistant/os-env";
import {
PROVIDER_DOCS_URL,
PROVIDER_FIELDS,
type ProviderField,
} from "../../lib/assistant/provider-fields";
import { AiSectionContent } from "./AiSectionContent";
export type SettingsSection =
| "map"
| "layout"
| "appearance"
| "interface"
| "geocoding"
| "ai"
| "environment"
| "updates";
/** A field a deep-link can ask Settings to focus once the section renders. */
export type SettingsFocusTarget = "shareToken" | "accentColor";
/** Window event letting any panel open Settings at a given section (no prop-drilling). */
export const OPEN_SETTINGS_EVENT = "geolibre:open-settings";
/**
* Open the Settings dialog at `section` from anywhere in the app, optionally
* focusing a specific field once that section renders (e.g. the Share dialog
* deep-links into Environment Variables and focuses the share token input).
*/
export function openSettingsSection(
section: SettingsSection,
options?: { focus?: SettingsFocusTarget },
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(OPEN_SETTINGS_EVENT, {
detail: { section, focus: options?.focus },
}),
);
}
/** A plugin offered as a visibility toggle in the Interface section. */
export interface ProfilePlugin {
id: string;
name: string;
}
interface SettingsDialogProps {
buttonClassName?: string;
buttonSize?: "default" | "sm" | "lg" | "icon" | null;
iconClassName?: string;
mapControllerRef: RefObject<MapController | null>;
showLabels?: boolean;
onOpenManagePlugins: () => void;
/** Toggleable plugins for the Interface (UI profile) section (issue #500). */
profilePlugins: ProfilePlugin[];
/** Current light/dark mode, surfaced as toggles in Appearance (issue #716). */
themeMode: ThemeMode;
/** Flip the light/dark mode; the Appearance cards drive the same toggle. */
onToggleThemeMode: () => void;
}
const SECTION_ITEMS: Array<{
id: SettingsSection;
labelKey: `settings.section.${SettingsSection}`;
icon: typeof MapPinned;
}> = [
{ id: "map", labelKey: "settings.section.map", icon: MapPinned },
{ id: "layout", labelKey: "settings.section.layout", icon: LayoutPanelTop },
{
id: "appearance",
labelKey: "settings.section.appearance",
icon: Palette,
},
{
id: "interface",
labelKey: "settings.section.interface",
icon: SlidersHorizontal,
},
{ id: "geocoding", labelKey: "settings.section.geocoding", icon: Locate },
{ id: "ai", labelKey: "settings.section.ai", icon: Bot },
{
id: "environment",
labelKey: "settings.section.environment",
icon: Braces,
},
{
id: "updates",
labelKey: "settings.section.updates",
icon: DownloadCloud,
},
];
// The menu-item id that gates each Settings section, mirroring the dropdown.
// Sections without an entry (Layout, Interface) always show so the profile UI
// stays reachable.
const SECTION_GATE: Partial<Record<SettingsSection, string>> = {
map: "settings.mapPreferences",
geocoding: "settings.geocoding",
environment: "settings.environment",
};
const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
// Draft env vars carry a stable client-side id so React can key the rows by
// identity. Keying by array index reuses input DOM state (focus, cursor)
// across the wrong item after a mid-list delete.
interface DraftEnvironmentVariable extends RuntimeEnvironmentVariable {
id: string;
}
interface DraftPreferences {
map: MapPreferences;
environmentVariables: DraftEnvironmentVariable[];
geocoding: ProjectPreferences["geocoding"];
}
interface DraftDesktopSettings {
layout: DesktopLayoutSettings;
shareToken: string;
cesiumIonToken: string;
aiProfiles: AssistantProfile[];
defaultAiProfileId: string | null;
uiProfile: UiProfileSettings;
updates: UpdateSettings;
}
function createDraftId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function clonePreferences(preferences: ProjectPreferences): DraftPreferences {
return {
map: { ...preferences.map },
environmentVariables: preferences.environmentVariables.map((variable) => ({
...variable,
id: createDraftId(),
})),
geocoding: {
...preferences.geocoding,
apiKeys: { ...preferences.geocoding.apiKeys },
},
};
}
function cloneDesktopSettings(settings: DesktopSettings): DraftDesktopSettings {
return {
layout: { ...settings.layout },
shareToken: settings.shareToken,
cesiumIonToken: settings.cesiumIonToken,
aiProfiles: settings.aiProfiles.map((p) => ({
...p,
fieldValues: { ...p.fieldValues },
})),
defaultAiProfileId: settings.defaultAiProfileId,
uiProfile: {
...settings.uiProfile,
hiddenDataSources: [...settings.uiProfile.hiddenDataSources],
hiddenPlugins: [...settings.uiProfile.hiddenPlugins],
hiddenMenus: [...settings.uiProfile.hiddenMenus],
hiddenMenuItems: [...settings.uiProfile.hiddenMenuItems],
},
updates: { ...settings.updates },
};
}
function normalizeBounds(bounds: MapPreferences["bounds"]): MapPreferences["bounds"] {
const west = clamp(bounds[0], -180, 180);
const south = clamp(bounds[1], -85, 85);
const east = clamp(bounds[2], -180, 180);
const north = clamp(bounds[3], -85, 85);
if (west >= east || south >= north) {
return DEFAULT_PROJECT_PREFERENCES.map.bounds;
}
return [west, south, east, north];
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function roundCoordinate(value: number): number {
return Number(value.toFixed(6));
}
function normalizePreferences(preferences: ProjectPreferences): ProjectPreferences {
const minZoom = clamp(preferences.map.minZoom, 0, 24);
const maxZoom = Math.max(minZoom, clamp(preferences.map.maxZoom, 0, 24));
return {
map: {
...preferences.map,
bounds: normalizeBounds(preferences.map.bounds),
minZoom,
maxZoom,
maxPitch: clamp(preferences.map.maxPitch, 0, 85),
},
environmentVariables: preferences.environmentVariables
.map((variable) => ({
key: variable.key.trim(),
value: variable.value,
enabled: variable.enabled,
}))
.filter((variable) => variable.key.length > 0),
geocoding: normalizeGeocodingPreferences(preferences.geocoding),
};
}
function normalizeGeocodingPreferences(
geocoding: ProjectPreferences["geocoding"],
): ProjectPreferences["geocoding"] {
const providerId = normalizeGeocodingProviderId(geocoding.providerId);
// Keep only non-empty keys so the saved project does not carry blank entries.
const apiKeys: Record<string, string> = {};
for (const [id, key] of Object.entries(geocoding.apiKeys)) {
if (key.trim()) apiKeys[id] = key.trim();
}
return {
providerId,
apiKeys,
forwardEndpoint: geocoding.forwardEndpoint?.trim() || undefined,
reverseEndpoint: geocoding.reverseEndpoint?.trim() || undefined,
email: geocoding.email?.trim() || undefined,
};
}
// Returned as a code (not a message) so the user-facing string is resolved
// through i18n at the call site, where `t` is in scope.
type EnvironmentValidationError = { kind: "pattern" } | { kind: "duplicate"; name: string };
function validateEnvironmentVariables(
variables: RuntimeEnvironmentVariable[],
): EnvironmentValidationError | null {
const keys = new Set<string>();
for (const variable of variables) {
const key = variable.key.trim();
if (!key) continue;
if (!VARIABLE_NAME_PATTERN.test(key)) {
return { kind: "pattern" };
}
if (keys.has(key)) {
return { kind: "duplicate", name: key };
}
keys.add(key);
}
return null;
}
export function SettingsDialog({
buttonClassName,
buttonSize = "sm",
iconClassName,
mapControllerRef,
showLabels = true,
onOpenManagePlugins,
profilePlugins,
themeMode,
onToggleThemeMode,
}: SettingsDialogProps) {
const { t } = useTranslation();
const { language, options: languageOptions, setLanguage } = useLanguage();
const preferences = useAppStore((s) => s.preferences);
const setPreferences = useAppStore((s) => s.setPreferences);
const desktopSettings = useDesktopSettingsStore((s) => s.desktopSettings);
const setDesktopSettings = useDesktopSettingsStore((s) => s.setDesktopSettings);
// Visibility of the Settings dropdown items under the active UI profile. The
// Language/Layout/Interface entries are always shown so the profile UI stays
// reachable.
const showSettingsItem = (id: string) => isMenuItemVisible(desktopSettings.uiProfile, id);
const [open, setOpen] = useState(false);
const [section, setSection] = useState<SettingsSection>("map");
// The Browser is a dockable right panel (open/close via the registry), not a
// persisted layout preference, so its Layout toggle acts on the live registry
// state directly rather than through the draft settings.
const rightPanelState = useRightPanelState();
const browserPanelOpen = rightPanelState.visibleIds.includes(BROWSER_PANEL_ID);
const commentsPanelOpen = rightPanelState.visibleIds.includes(COMMENTS_PANEL_ID);
// Show it collapsed on the shared Layers rail, matching its default state, so
// re-enabling from Settings doesn't jump to an expanded panel that buries the
// Layers panel.
const toggleBrowserPanel = (show: boolean) => {
if (show) {
openRightPanel(BROWSER_PANEL_ID);
collapseRightPanel(BROWSER_PANEL_ID);
} else {
closeRightPanel(BROWSER_PANEL_ID);
}
};
const toggleCommentsPanel = (show: boolean) => {
if (show) {
openRightPanel(COMMENTS_PANEL_ID);
collapseRightPanel(COMMENTS_PANEL_ID);
} else {
closeRightPanel(COMMENTS_PANEL_ID);
}
};
// A field a deep-link asked us to focus once its section renders; cleared
// after the focus lands so a later open without a focus request stays put.
const [pendingFocus, setPendingFocus] = useState<SettingsFocusTarget | null>(null);
const shareTokenInputRef = useRef<HTMLInputElement>(null);
// The native color input in the Appearance pane. The accent-color dropdown's
// "Custom" entry deep-links here so picking a custom color is reachable
// without a third-level menu (#718).
const customColorInputRef = useRef<HTMLInputElement>(null);
// The nav button for the active section. Focus follows the active section so
// the focus ring never strands on a different item than the visible pane
// (Safari keeps focus on the previously focused button after a mouse click,
// so the ring would otherwise stay on the first item, see #713).
const activeSectionButtonRef = useRef<HTMLButtonElement>(null);
// After the deep-link effect focuses its target field and clears
// `pendingFocus`, the nav-focus effect re-runs (pendingFocus is in its deps)
// and would steal focus back. This guards exactly that one re-run so the
// deep-linked field keeps focus (#720 review).
const skipNextNavFocusRef = useRef(false);
// A gated section is dropped from the nav, but `section` can still point at one
// (its initial value is "map"), so render the first visible section instead to
// never expose gated content to a restricted profile.
const isSectionVisible = (id: SettingsSection) => {
// Automated update checks run in the desktop build only, so the section is
// hidden on the web where its controls would be inert.
if (id === "updates" && !isTauri()) return false;
// The Microsoft Store build has no in-app update flow to configure (policy
// 10.2.5), so its settings section is dropped entirely.
if (id === "updates" && IS_STORE_BUILD) return false;
const gate = SECTION_GATE[id];
return gate ? showSettingsItem(gate) : true;
};
const effectiveSection: SettingsSection = isSectionVisible(section)
? section
: // "interface" has no gate, so it is always a valid, visible fallback.
(SECTION_ITEMS.find((item) => isSectionVisible(item.id))?.id ?? "interface");
const [draftPreferences, setDraftPreferences] = useState<DraftPreferences>(() =>
clonePreferences(preferences),
);
const [draftDesktopSettings, setDraftDesktopSettings] = useState<DraftDesktopSettings>(() =>
cloneDesktopSettings(desktopSettings),
);
const [error, setError] = useState<string | null>(null);
// Live map projection, captured when the dialog opens. The Globe projection
// lets the map drift slightly past restricted bounds, so we warn users to
// switch to Mercator before capturing the current view (see #505).
const [liveProjection, setLiveProjection] = useState<MapProjection | null>(null);
// Ids of variables whose value is temporarily revealed; values are masked
// by default so secrets are not shown on screen.
const [revealedValueIds, setRevealedValueIds] = useState<Set<string>>(() => new Set());
const enabledVariableCount = useMemo(
() =>
draftPreferences.environmentVariables.filter(
(variable) => variable.enabled && variable.key.trim(),
).length,
[draftPreferences.environmentVariables],
);
// The AI profile being edited in the AI section. Null when no profile is
// selected (the user sees the profile list). Seeded to the first existing
// profile when the dialog opens.
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
// Whether the user is creating a new profile (transient — no id yet).
const [isCreatingProfile, setIsCreatingProfile] = useState(false);
// The draft env vars as a plain name→value map (enabled, named only), matching
// what the live runtime env will hold after Save. Drives the per-provider
// "configured" status without re-implementing provider.ts resolution.
const draftEnv = useMemo(() => {
const env: Record<string, string> = {};
for (const variable of draftPreferences.environmentVariables) {
const key = variable.key.trim();
if (variable.enabled && key) env[key] = variable.value;
}
return env;
}, [draftPreferences.environmentVariables]);
/** The editing profile (the one whose fields are shown), or null. */
const editingProfile: AssistantProfile | null = useMemo(() => {
if (isCreatingProfile) return null;
if (!editingProfileId) return null;
return draftDesktopSettings.aiProfiles.find((p) => p.id === editingProfileId) ?? null;
}, [editingProfileId, isCreatingProfile, draftDesktopSettings.aiProfiles]);
/** The provider shown in the editing fields. Derived from the editing profile. */
const editingProvider: AssistantProviderId = editingProfile?.provider ?? "google";
/**
* Flat env map from all profiles' fieldValues. Projected into the runtime env
* alongside OS and project values so provider "configured" status reflects
* what the assistant will actually resolve.
*/
const draftProfilesEnv = useMemo(() => {
const env: Record<string, string> = {};
for (const profile of draftDesktopSettings.aiProfiles) {
for (const [key, value] of Object.entries(profile.fieldValues)) {
const name = key.trim();
if (name && value) env[name] = value;
}
}
return env;
}, [draftDesktopSettings.aiProfiles]);
// AI keys read from the user's OS environment (desktop only). This dialog is
// mounted eagerly at startup — before the App-root loader populates the cache
// and before the async Tauri read resolves — so a mount-only read would freeze
// at `{}`. Load it here through state (mirroring useRuntimeEnvironmentVariables)
// so provider status and the badges below reflect env-sourced credentials.
const [osEnv, setOsEnv] = useState<RuntimeEnv>(() => readOsEnv());
useEffect(() => {
let cancelled = false;
loadOsEnvVars().then((env) => {
if (!cancelled) setOsEnv(env);
});
return () => {
cancelled = true;
};
}, []);
// Scope OS values against the draft exactly as the runtime merge does
// (useRuntimeEnvironmentVariables), so this dialog's notion of "configured"
// and the field badges match what the assistant will actually resolve — a
// plain spread would disagree in the alias-collision case (e.g. an empty
// project GOOGLE_API_KEY row shadows the whole Google OS alias group).
const scopedOsEnv = useMemo(
() =>
scopeOsEnvToProject(
osEnv,
new Set([...Object.keys(draftEnv), ...Object.keys(draftProfilesEnv)]),
),
[osEnv, draftEnv, draftProfilesEnv],
);
// Merge OS env under the drafts so a provider configured purely via a system
// environment variable still reports "ready". Precedence mirrors the live
// runtime merge: OS < device AI keys < project Environment variables.
const effectiveEnv = useMemo(
() => ({ ...scopedOsEnv, ...draftProfilesEnv, ...draftEnv }),
[scopedOsEnv, draftProfilesEnv, draftEnv],
);
// Seed the draft from the store only when the dialog opens. Depending on
// preferences would reset in-progress edits if the store changed while the
// dialog is open (e.g. a slow ?url= project finishes loading).
useEffect(() => {
if (!open) {
// Clear so the stale projection can't flash the Globe hint for a frame
// on the next open before this effect re-reads it.
setLiveProjection(null);
// Drop any pending focus request too: if the dialog closes before the
// focus RAF fires, a leftover target would otherwise fire on a later
// open that never asked for it.
setPendingFocus(null);
// Discard any uncommitted hex text so a half-typed/invalid value never
// resurfaces on the next open (the swatch already shows the saved color),
// and clear the Escape skip flag so a leftover true can't swallow the
// first edit of the next session.
skipCustomColorCommitRef.current = false;
setCustomColorDraft(null);
return;
}
const seededPreferences = clonePreferences(useAppStore.getState().preferences);
setDraftPreferences(seededPreferences);
setDraftDesktopSettings(
cloneDesktopSettings(useDesktopSettingsStore.getState().desktopSettings),
);
// Land the AI section on the first profile's provider, or the first
// available provider if no profiles exist, so the user sees something
// relevant without extra clicks.
const storeSettings = useDesktopSettingsStore.getState().desktopSettings;
const seededProfiles = storeSettings.aiProfiles.map((p) => ({
...p,
fieldValues: { ...p.fieldValues },
}));
const seededProjectEnv: Record<string, string> = {};
for (const variable of seededPreferences.environmentVariables) {
const key = variable.key.trim();
if (variable.enabled && key) seededProjectEnv[key] = variable.value;
}
// Build a flat env from all profile field values for determining
// available providers during seeding.
const seededAiEnv: Record<string, string> = {};
for (const profile of seededProfiles) {
for (const [key, value] of Object.entries(profile.fieldValues)) {
const name = key.trim();
if (name && value) seededAiEnv[name] = value;
}
}
const seededEnv = {
...scopeOsEnvToProject(
readOsEnv(),
new Set([...Object.keys(seededProjectEnv), ...Object.keys(seededAiEnv)]),
),
...seededAiEnv,
...seededProjectEnv,
};
// Show the profile list by default (do not auto-select a profile for editing).
setEditingProfileId(null);
setIsCreatingProfile(false);
setRevealedValueIds(new Set());
setError(null);
setLiveProjection(mapControllerRef.current?.readProjection() ?? null);
}, [open, mapControllerRef]);
// Let other panels deep-link into a specific Settings section (e.g. the AI
// Assistant onboarding card opens the AI Providers section to add credentials).
useEffect(() => {
const onOpenSettings = (event: Event) => {
const detail = (
event as CustomEvent<{
section?: SettingsSection;
focus?: SettingsFocusTarget;
}>
).detail;
// setSection before setOpen so the section is already in state when React
// renders the open dialog (effectiveSection derives from it at render
// time). Only honor the request when the active UI profile actually shows
// that section; otherwise effectiveSection would silently fall back to
// another tab. The profile is read fresh (not via the effect's closure) so
// a profile change after mount is respected.
const requested = detail?.section;
// Stays false unless a requested section is actually navigated to, so a
// focus request without a (shown) section can't strand on whatever tab
// happens to be active.
let sectionShown = false;
if (requested) {
const gate = SECTION_GATE[requested];
const profile = useDesktopSettingsStore.getState().desktopSettings.uiProfile;
sectionShown = !gate || isMenuItemVisible(profile, gate);
if (sectionShown) setSection(requested);
}
// Only queue the focus when its target section is actually shown, so the
// request can't strand on a tab the profile hid.
setPendingFocus(detail?.focus && sectionShown ? detail.focus : null);
setOpen(true);
};
window.addEventListener(OPEN_SETTINGS_EVENT, onOpenSettings);
return () => window.removeEventListener(OPEN_SETTINGS_EVENT, onOpenSettings);
}, []);
// Focus a deep-linked field once its section has rendered. The token input
// only mounts when the Environment section is active, so this waits for the
// section to settle rather than focusing on open.
useEffect(() => {
if (!open || pendingFocus !== "shareToken") return;
if (effectiveSection !== "environment") return;
const id = window.requestAnimationFrame(() => {
shareTokenInputRef.current?.focus();
shareTokenInputRef.current?.select();
// Set the guard BEFORE clearing pendingFocus: the clear re-runs the
// nav-focus effect, and because this write is synchronous and lexically
// first, the ref is already true when that run reads it, so it skips and
// leaves focus on the field we just focused.
skipNextNavFocusRef.current = true;
setPendingFocus(null);
});
return () => window.cancelAnimationFrame(id);
}, [open, pendingFocus, effectiveSection]);
// Focus (and try to open) the custom-color picker when the accent-color
// dropdown deep-links into the Appearance pane. The input only mounts while
// the custom scheme is active, so wait for the section to settle first (#718).
useEffect(() => {
if (!open || pendingFocus !== "accentColor") return;
if (effectiveSection !== "appearance") return;
const id = window.requestAnimationFrame(() => {
const input = customColorInputRef.current;
input?.focus();
// Pop the native picker straight away when the browser allows it; if the
// user gesture has lapsed it throws, leaving the focused input as the
// fallback rather than a dead end.
try {
input?.showPicker();
} catch {
// No transient user activation; the focused input is enough.
}
skipNextNavFocusRef.current = true;
setPendingFocus(null);
});
return () => window.cancelAnimationFrame(id);
}, [open, pendingFocus, effectiveSection]);
// Keep the focus ring on the active section's nav button. Without this the
// ring strands on whichever button was focused when the dialog opened (the
// first one, or where a click left it on Safari) while the highlight and pane
// move to the selected section (#713). Skipped while a deep-link focus is
// pending so it does not steal focus from the field that request targets.
useEffect(() => {
if (!open || pendingFocus) return;
if (skipNextNavFocusRef.current) {
skipNextNavFocusRef.current = false;
return;
}
const id = window.requestAnimationFrame(() => {
activeSectionButtonRef.current?.focus();
});
return () => window.cancelAnimationFrame(id);
}, [open, pendingFocus, effectiveSection]);
const toggleValueVisibility = (id: string) => {
setRevealedValueIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Every env var name a field is backed by: its canonical key plus any aliases
// provider.ts also accepts (e.g. GOOGLE_API_KEY for the Gemini field).
const fieldEnvKeys = (field: ProviderField): readonly string[] => [
field.envKey,
...(field.aliases ?? []),
];
// The value of an env-var-backed AI provider field, or "" when unset. Reads
// the editing profile's field values first (where the AI section saves keys),
// then falls back to a matching value still held in the project's Environment
// variables so an existing credential stays visible and editable here.
const getProviderField = (field: ProviderField): string => {
if (editingProfile) {
for (const key of fieldEnvKeys(field)) {
const value = editingProfile.fieldValues[key];
if (value) return value;
}
}
for (const key of fieldEnvKeys(field)) {
const row = draftPreferences.environmentVariables.find(
(variable) => variable.key === key && variable.enabled,
);
if (row) return row.value;
}
return "";
};
// The OS-environment variable name backing a field, when the system provides
// a value the project doesn't. Drives the "read from your environment" badge
// so a user sees a key is already covered without typing (or saving) it here.
const osFieldEnvName = (field: ProviderField): string | null => {
for (const key of fieldEnvKeys(field)) {
if (scopedOsEnv[key]?.trim()) return key;
}
return null;
};
// Write an AI provider field to the editing profile's field values. Any alias
// entry is dropped so re-entering a credential never leaves a stale duplicate
// under an alias, and clearing removes the entry so the store never accrues
// empty values. The matching rows are also removed from the project's
// Environment variables: these keys now live in the profile, so a leftover
// project row must not shadow the profile value at runtime (project env has
// higher precedence) nor get serialized into a shared project file.
const setProviderField = (field: ProviderField, value: string) => {
if (!editingProfile) return;
const keys = fieldEnvKeys(field);
setDraftDesktopSettings((current) => {
const next = current.aiProfiles.map((p) => {
if (p.id !== editingProfile.id) return p;
const nextFieldValues = { ...p.fieldValues };
for (const key of keys) delete nextFieldValues[key];
if (value !== "") nextFieldValues[field.envKey] = value;
return { ...p, fieldValues: nextFieldValues };
});
return { ...current, aiProfiles: next };
});
setDraftPreferences((current) => {
if (!current.environmentVariables.some((v) => keys.includes(v.key))) {
return current;
}
return {
...current,
environmentVariables: current.environmentVariables.filter((v) => !keys.includes(v.key)),
};
});
setError(null);
};
const updateMapPreferences = (patch: Partial<MapPreferences>) => {
setDraftPreferences((current) => ({
...current,
map: { ...current.map, ...patch },
}));
setError(null);
};
const updateBoundsValue = (index: number, value: number) => {
// Ignore a cleared field (valueAsNumber is NaN) so it does not silently
// become an edge-of-range value on save; the last valid value is kept.
if (!Number.isFinite(value)) return;
setDraftPreferences((current) => {
const bounds: MapPreferences["bounds"] = [...current.map.bounds];
bounds[index] = value;
return {
...current,
map: { ...current.map, bounds },
};
});
setError(null);
};
const updateEnvironmentVariable = (index: number, patch: Partial<RuntimeEnvironmentVariable>) => {
setDraftPreferences((current) => ({
...current,
environmentVariables: current.environmentVariables.map((variable, i) =>
i === index ? { ...variable, ...patch } : variable,
),
}));
setError(null);
};
const addEnvironmentVariable = () => {
setDraftPreferences((current) => ({
...current,
environmentVariables: [
...current.environmentVariables,
{ id: createDraftId(), key: "", value: "", enabled: true },
],
}));
setSection("environment");
setError(null);
};
const removeEnvironmentVariable = (index: number) => {
setDraftPreferences((current) => ({
...current,
environmentVariables: current.environmentVariables.filter((_, i) => i !== index),
}));
setError(null);
};
const applyCurrentViewBounds = () => {
const bounds = mapControllerRef.current?.readView().bbox;
if (!bounds) {
setError(t("settings.map.errorBoundsUnavailable"));
return;
}
updateMapPreferences({
restrictBounds: true,
bounds: [
roundCoordinate(bounds[0]),
roundCoordinate(bounds[1]),
roundCoordinate(bounds[2]),
roundCoordinate(bounds[3]),
],
});
};
const resetMapPreferences = () => {
updateMapPreferences(DEFAULT_PROJECT_PREFERENCES.map);
};
const updateGeocoding = (patch: Partial<ProjectPreferences["geocoding"]>) => {
setDraftPreferences((current) => ({
...current,
geocoding: { ...current.geocoding, ...patch },
}));
setError(null);
};
const updateGeocodingApiKey = (providerId: string, value: string) => {
setDraftPreferences((current) => ({
...current,
geocoding: {
...current.geocoding,
apiKeys: { ...current.geocoding.apiKeys, [providerId]: value },
},
}));
setError(null);
};
const updateDraftLayoutSettings = (patch: Partial<DesktopLayoutSettings>) => {
setDraftDesktopSettings((current) => ({
...current,
layout: { ...current.layout, ...patch },
}));
setError(null);
};
const updateSavedLayoutSettings = (patch: Partial<DesktopLayoutSettings>) => {
// Read the latest state synchronously so rapid successive toggles do not
// overwrite each other with a stale render-closure snapshot.
const current = useDesktopSettingsStore.getState().desktopSettings;
setDesktopSettings({
...current,
layout: { ...current.layout, ...patch },
});
};
const resetLayoutSettings = () => {
updateDraftLayoutSettings(DEFAULT_DESKTOP_LAYOUT_SETTINGS);
};
// The accent scheme applies live (instant preview) rather than waiting for
// Save, mirroring the Interface profile toggles. Reads the latest state so a
// rapid click after another live change does not clobber it with a stale
// render-closure snapshot.
const updateSavedThemeScheme = (scheme: ThemeScheme) => {
const current = useDesktopSettingsStore.getState().desktopSettings;
setDesktopSettings({ ...current, theme: { ...current.theme, scheme } });
};
// Picking a custom color both stores the color and activates the custom scheme,
// so editing the swatch immediately previews it.
const updateSavedThemeCustomColor = (customColor: string) => {
const current = useDesktopSettingsStore.getState().desktopSettings;
setDesktopSettings({
...current,
theme: { ...current.theme, scheme: "custom", customColor },
});
};
// In-progress text for the inline hex field next to the swatch. While the user
// is typing or pasting a code it holds the raw string; `null` means the field
// mirrors the saved color. On commit a valid 3- or 6-digit hex applies and an
// invalid one is discarded, so the field reverts to the last valid color (#911).
const [customColorDraft, setCustomColorDraft] = useState<string | null>(null);
// Set just before the Escape-triggered blur so the imminent blur discards the
// draft instead of committing it: the dialog still closes (its own Escape
// handler), but the typed value is cancelled rather than applied on the way out.
const skipCustomColorCommitRef = useRef(false);
const commitCustomColorDraft = () => {
if (skipCustomColorCommitRef.current) {
skipCustomColorCommitRef.current = false;
setCustomColorDraft(null);
return;
}
if (customColorDraft === null) return;
const normalized = normalizeHexColor(customColorDraft);
if (normalized) updateSavedThemeCustomColor(normalized);
setCustomColorDraft(null);
};
const updateDraftUpdateSettings = (patch: Partial<UpdateSettings>) => {
setDraftDesktopSettings((current) => ({
...current,
updates: { ...current.updates, ...patch },
}));
setError(null);
};
const resetUpdateSettings = () => {
updateDraftUpdateSettings(DEFAULT_UPDATE_SETTINGS);
};
// Live updates from the Settings dropdown's Interface submenu (not the draft,
// which only the dialog commits on Save). Reads the latest state so rapid
// toggles do not clobber each other.
const updateSavedUiProfile = (patch: Partial<UiProfileSettings>) => {
const current = useDesktopSettingsStore.getState().desktopSettings;
setDesktopSettings({
...current,
uiProfile: { ...current.uiProfile, ...patch },
});
};
const applySavedExperiencePreset = (level: ExperienceLevel) => {
const sets = presetHiddenSets(
level,
profilePlugins.map((plugin) => plugin.id),
);
updateSavedUiProfile({ enabled: true, level, ...sets });
};
// "Custom" counterpart for the Settings dropdown: opt into custom mode while
// preserving the existing hidden lists (issue #592).
const applySavedCustomProfile = () => {
updateSavedUiProfile({ enabled: true, level: null });
};
const updateShareToken = (value: string) => {
// Kept in the draft and only committed on Save, so editing the token and
// then closing the dialog without saving discards the change (a secret
// field should not persist on every keystroke).
setDraftDesktopSettings((current) => ({ ...current, shareToken: value }));
};
const updateCesiumIonToken = (value: string) => {
// Draft-only until Save, like the share token above (a secret field should
// not persist on every keystroke).
setDraftDesktopSettings((current) => ({ ...current, cesiumIonToken: value }));
};
const updateUiProfile = (patch: Partial<UiProfileSettings>) => {
setDraftDesktopSettings((current) => ({
...current,
uiProfile: { ...current.uiProfile, ...patch },