@jxsuite/studio 2.4.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
- package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
- package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
- package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
- package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
- package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
- package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
- package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
- package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
- package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
- package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
- package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
- package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
- package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
- package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
- package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
- package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
- package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
- package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
- package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
- package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
- package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
- package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
- package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
- package/dist/chunks/studio-kfpek536.js +28 -0
- package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
- package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
- package/dist/chunks/studio-qpfq7xhm.js +1802 -0
- package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
- package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
- package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
- package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
- package/dist/iframe-entry.js +344 -26
- package/dist/studio.js +374 -248
- package/package.json +9 -9
- package/src/browse/library-layouts.ts +3 -3
- package/src/browse/library-pane.ts +2 -2
- package/src/browse/library-preview.ts +19 -3
- package/src/canvas/asset-refs.ts +119 -0
- package/src/canvas/asset-resolve.ts +256 -0
- package/src/canvas/canvas-live-render.ts +23 -15
- package/src/canvas/iframe-entry.ts +1 -0
- package/src/canvas/iframe-host.ts +4 -0
- package/src/canvas/iframe-protocol.ts +9 -0
- package/src/canvas/iframe-render.ts +28 -10
- package/src/canvas/site-style-css.ts +2 -6
- package/src/editor/file-drop-action.ts +2 -1
- package/src/files/files.ts +2 -2
- package/src/files/media-paths.ts +37 -14
- package/src/files/media-upload.ts +47 -6
- package/src/grid/grid-idle.ts +45 -0
- package/src/grid/grid-view.ts +21 -0
- package/src/grid/schema-columns.ts +2 -1
- package/src/grid/sources/content-source.ts +24 -3
- package/src/new-project/import-tab.ts +3 -2
- package/src/panels/ai-chat/composer.ts +30 -13
- package/src/panels/frontmatter-fields.ts +6 -2
- package/src/panels/properties-panel.ts +8 -2
- package/src/panels/seo-modal.ts +5 -6
- package/src/panels/signals-panel.ts +2 -1
- package/src/platforms/cloud.ts +75 -5
- package/src/platforms/devserver.ts +5 -1
- package/src/services/ai-models.ts +101 -11
- package/src/services/ai-settings.ts +69 -71
- package/src/services/cf-settings.ts +19 -37
- package/src/services/document-assistant.ts +4 -3
- package/src/services/idle.ts +6 -1
- package/src/services/settings/definitions.ts +164 -0
- package/src/services/settings/kernel.ts +335 -0
- package/src/services/settings/write-queue.ts +116 -0
- package/src/settings/preferences-accounts.ts +6 -19
- package/src/settings/preferences-keymap.ts +8 -18
- package/src/settings/schema-field-ui.ts +2 -2
- package/src/shell.ts +6 -14
- package/src/studio.ts +32 -1
- package/src/types.ts +61 -3
- package/src/ui/ai-credentials-form.ts +52 -27
- package/src/ui/media-picker.ts +6 -11
- package/src/utils/studio-utils.ts +45 -3
- package/dist/chunks/studio-a7pzveq1.js +0 -110
- package/src/canvas/content-assets.ts +0 -263
- package/src/services/settings-store.ts +0 -99
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings registry — every application setting Studio holds, declared once.
|
|
3
|
+
*
|
|
4
|
+
* Before this table the answer to "which settings roam, and what is each one's default" lived in
|
|
5
|
+
* four places: a `PERSISTED_SETTINGS_KEYS` array, a private `const` per key in each `*-settings.ts`,
|
|
6
|
+
* a bespoke getter/setter pair beside it, and whatever the getter's `||` happened to fall back to.
|
|
7
|
+
* Adding a roaming setting meant editing all four and hoping; the key strings themselves were
|
|
8
|
+
* duplicated between the array and the module that owned them.
|
|
9
|
+
*
|
|
10
|
+
* Two properties of this table are load-bearing rather than tidy:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Scope is declared, not inferred.** {@link USER_SETTINGS} is DERIVED from it, so a setting
|
|
13
|
+
* roams because it says `scope: "user"` — not because someone remembered to add its key to a
|
|
14
|
+
* second list. `device` is the honest name for the rest: panel geometry and a palette's MRU
|
|
15
|
+
* describe *this window*, and a user with two windows open wants them to differ.
|
|
16
|
+
* 2. **A default is not a value.** The default is what a *reader* sees when nothing is stored; it
|
|
17
|
+
* is never itself stored. `getModel()` used to mask unset as `"gpt-4o"` with `||`, a form
|
|
18
|
+
* prefilled from it, and Save wrote the mask back — which is how an install whose owner had
|
|
19
|
+
* configured an entirely different provider was left holding `jx.ai.model: "gpt-4o"` and
|
|
20
|
+
* nothing else. {@link readSetting} and {@link readStoredSetting} in `kernel.ts` are two
|
|
21
|
+
* functions for exactly this reason.
|
|
22
|
+
*
|
|
23
|
+
* @license MIT
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a setting lives.
|
|
28
|
+
*
|
|
29
|
+
* `user` follows the author — every window, every project, and (where the platform has a backend
|
|
30
|
+
* store) every browser profile the launcher hands out. `device` stays where it was written.
|
|
31
|
+
*/
|
|
32
|
+
export type SettingScope = "user" | "device";
|
|
33
|
+
|
|
34
|
+
/** One setting, as the registry declares it. */
|
|
35
|
+
export interface SettingDefinition {
|
|
36
|
+
/** The storage key, in both localStorage and the backend map. */
|
|
37
|
+
key: string;
|
|
38
|
+
scope: SettingScope;
|
|
39
|
+
/** What a reader sees when nothing is stored. Never written — see the module docblock. */
|
|
40
|
+
default: string;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the value is a credential.
|
|
43
|
+
*
|
|
44
|
+
* Nothing in the kernel treats a secret differently; the flag is what lets a surface refuse to
|
|
45
|
+
* print one, a diagnostic dump refuse to include one, and the Accounts list report _that_ one is
|
|
46
|
+
* held rather than what it is (`specs/studio.md` §15 rule 1).
|
|
47
|
+
*/
|
|
48
|
+
secret?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Canonicalise an incoming value before it is stored. Total: it is handed raw user input.
|
|
51
|
+
*
|
|
52
|
+
* Applied on write only. A value already on disk is returned as it was found, so a normalizer
|
|
53
|
+
* that tightens later cannot silently rewrite what someone else stored.
|
|
54
|
+
*/
|
|
55
|
+
normalize?: (raw: string) => string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Trim, and drop trailing slashes — an endpoint is a base, not a path. */
|
|
59
|
+
function normalizeBaseUrl(raw: string): string {
|
|
60
|
+
return raw.trim().replace(/\/+$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Trim. Credentials are pasted, and a paste carries whitespace. */
|
|
64
|
+
function trimmed(raw: string): string {
|
|
65
|
+
return raw.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every setting, keyed by the name the rest of the app refers to it by.
|
|
70
|
+
*
|
|
71
|
+
* Declared `as const satisfies` so each entry keeps its literal `key` for callers while still being
|
|
72
|
+
* checked against {@link SettingDefinition}.
|
|
73
|
+
*/
|
|
74
|
+
export const SETTINGS = {
|
|
75
|
+
aiBaseUrl: {
|
|
76
|
+
default: "",
|
|
77
|
+
key: "jx.ai.baseUrl",
|
|
78
|
+
normalize: normalizeBaseUrl,
|
|
79
|
+
scope: "user",
|
|
80
|
+
},
|
|
81
|
+
/**
|
|
82
|
+
* The provider key. Stays in the settings store rather than moving to the launcher's credential
|
|
83
|
+
* store: the webview is its legitimate consumer — it sends the key itself as `X-Api-Key` — so
|
|
84
|
+
* withholding it and then transmitting it would be the same trust boundary with more parts. See
|
|
85
|
+
* `specs/desktop.md` §3.6, which draws that line.
|
|
86
|
+
*/
|
|
87
|
+
aiOpenAiKey: {
|
|
88
|
+
default: "",
|
|
89
|
+
key: "jx.ai.openaiKey",
|
|
90
|
+
normalize: trimmed,
|
|
91
|
+
scope: "user",
|
|
92
|
+
secret: true,
|
|
93
|
+
},
|
|
94
|
+
/**
|
|
95
|
+
* `"gpt-4o"` is what a sender falls back to, NOT a stored choice. A surface that writes back must
|
|
96
|
+
* read through `readStoredSetting`, or it persists a model the user never picked.
|
|
97
|
+
*/
|
|
98
|
+
aiModel: {
|
|
99
|
+
default: "gpt-4o",
|
|
100
|
+
key: "jx.ai.model",
|
|
101
|
+
normalize: trimmed,
|
|
102
|
+
scope: "user",
|
|
103
|
+
},
|
|
104
|
+
/**
|
|
105
|
+
* The author's rebindings, as a JSON object of `command id → chords`.
|
|
106
|
+
*
|
|
107
|
+
* Roams, so a keyboard learned once is the keyboard everywhere. `normalize` is why the field
|
|
108
|
+
* exists on {@link SettingDefinition}: an empty override map is the absence of an override, and
|
|
109
|
+
* storing `"{}"` would make every window think a layer had been authored.
|
|
110
|
+
*/
|
|
111
|
+
keybindings: {
|
|
112
|
+
default: "",
|
|
113
|
+
key: "jx.keybindings",
|
|
114
|
+
normalize: (raw) => (raw.trim() === "{}" ? "" : raw.trim()),
|
|
115
|
+
scope: "user",
|
|
116
|
+
},
|
|
117
|
+
/**
|
|
118
|
+
* The chrome theme.
|
|
119
|
+
*
|
|
120
|
+
* Read at module evaluation, before the first paint — `shell.ts` says a mismatch with the theme
|
|
121
|
+
* `index.html` hard-codes flashes the shell. That is the reason the kernel keeps a synchronous
|
|
122
|
+
* cache rather than awaiting the backend.
|
|
123
|
+
*/
|
|
124
|
+
theme: {
|
|
125
|
+
default: "",
|
|
126
|
+
key: "jx-studio-theme",
|
|
127
|
+
normalize: trimmed,
|
|
128
|
+
scope: "user",
|
|
129
|
+
},
|
|
130
|
+
cfAccountId: {
|
|
131
|
+
default: "",
|
|
132
|
+
key: "jx.cf.accountId",
|
|
133
|
+
normalize: trimmed,
|
|
134
|
+
scope: "user",
|
|
135
|
+
},
|
|
136
|
+
cfToken: {
|
|
137
|
+
default: "",
|
|
138
|
+
key: "jx.cf.token",
|
|
139
|
+
normalize: trimmed,
|
|
140
|
+
scope: "user",
|
|
141
|
+
secret: true,
|
|
142
|
+
},
|
|
143
|
+
} as const satisfies Record<string, SettingDefinition>;
|
|
144
|
+
|
|
145
|
+
/** The registry's own names — `SETTINGS.aiModel`, not `"jx.ai.model"`. */
|
|
146
|
+
export type SettingName = keyof typeof SETTINGS;
|
|
147
|
+
|
|
148
|
+
/** Every declared setting, in declaration order. */
|
|
149
|
+
export const ALL_SETTINGS: readonly SettingDefinition[] = Object.values(SETTINGS);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The settings that roam, derived from {@link SETTINGS}.
|
|
153
|
+
*
|
|
154
|
+
* This replaces the hand-maintained `PERSISTED_SETTINGS_KEYS`. A setting joins it by declaring
|
|
155
|
+
* `scope: "user"` and by no other means, so the list cannot fall behind the table it describes.
|
|
156
|
+
*/
|
|
157
|
+
export const USER_SETTINGS: readonly SettingDefinition[] = ALL_SETTINGS.filter(
|
|
158
|
+
(definition) => definition.scope === "user",
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
/** The roaming keys as strings — what a backend map is keyed by. */
|
|
162
|
+
export const USER_SETTING_KEYS: readonly string[] = USER_SETTINGS.map(
|
|
163
|
+
(definition) => definition.key,
|
|
164
|
+
);
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The settings kernel — one owner for every application setting's value.
|
|
4
|
+
*
|
|
5
|
+
* **The model.** The backend store is authoritative, `localStorage` is a synchronous cache of it,
|
|
6
|
+
* and every write is a per-key patch applied by whoever owns the file. What this replaces collapsed
|
|
7
|
+
* all three roles: `localStorage` was the truth, and it was mirrored to disk by whole-map replaces
|
|
8
|
+
* computed from whichever window happened to write last. On the chromium launcher — where every
|
|
9
|
+
* window is its own process with its own browser profile, and so its own `localStorage` — a window
|
|
10
|
+
* holding no settings could therefore overwrite the shared file with an almost empty map. Nothing
|
|
11
|
+
* told the other window it had happened.
|
|
12
|
+
*
|
|
13
|
+
* **Why a Map and not just localStorage.** Reads must be synchronous: the chrome theme is read
|
|
14
|
+
* before the first paint (`shell.ts` says so explicitly — a mismatch flashes the shell), and every
|
|
15
|
+
* credential gate reads on the render path. But the authoritative copy arrives asynchronously. An
|
|
16
|
+
* in-memory Map seeded from the cache at module evaluation gives both: correct on the first frame,
|
|
17
|
+
* and reconciled by {@link hydrateSettings} the moment the backend answers.
|
|
18
|
+
*
|
|
19
|
+
* **The one deletion path.** {@link setSetting} with `""` STORES an empty string;
|
|
20
|
+
* {@link clearSetting} is the only thing that removes. Blank used to mean delete, which read as a
|
|
21
|
+
* convenience until a form that blanked its own drafts on save called the setters with them — and
|
|
22
|
+
* pressing Save a second time revoked the credentials the first press had stored. Making blank
|
|
23
|
+
* unrepresentable as a deletion is what stops that whole class, rather than fixing the one caller.
|
|
24
|
+
*
|
|
25
|
+
* @license MIT
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { getPlatform, hasPlatform } from "../../platform";
|
|
29
|
+
import { notify } from "../notify";
|
|
30
|
+
import { ALL_SETTINGS, USER_SETTINGS } from "./definitions";
|
|
31
|
+
import type { SettingDefinition } from "./definitions";
|
|
32
|
+
import { createWriteQueue } from "./write-queue";
|
|
33
|
+
import type { SettingsPatch } from "./write-queue";
|
|
34
|
+
|
|
35
|
+
/** The synchronous source of truth. Absent key means unset — which is not the same as `""`. */
|
|
36
|
+
const values = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
/** Mirror a value into the cache, so the next boot is correct before the backend answers. */
|
|
39
|
+
function writeCache(key: string, value: string | null): void {
|
|
40
|
+
try {
|
|
41
|
+
if (value === null) {
|
|
42
|
+
globalThis.localStorage?.removeItem(key);
|
|
43
|
+
} else {
|
|
44
|
+
globalThis.localStorage?.setItem(key, value);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
/* Storage unavailable (private mode) — the Map still serves this session. */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Seed from the cache, at module evaluation and before anything can read.
|
|
53
|
+
*
|
|
54
|
+
* This is what makes the first frame correct: the chrome theme is read before the first paint, and
|
|
55
|
+
* `shell.ts` records that a mismatch with the theme `index.html` hard-codes flashes the shell. The
|
|
56
|
+
* backend's copy arrives later and reconciles through {@link hydrateSettings}.
|
|
57
|
+
*
|
|
58
|
+
* One `try` around the whole loop rather than one per key — storage that throws throws for all of
|
|
59
|
+
* them, and a private-mode window simply starts empty.
|
|
60
|
+
*/
|
|
61
|
+
try {
|
|
62
|
+
for (const definition of ALL_SETTINGS) {
|
|
63
|
+
const cached = globalThis.localStorage?.getItem(definition.key);
|
|
64
|
+
if (cached !== null && cached !== undefined) {
|
|
65
|
+
values.set(definition.key, cached);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
/* Storage unavailable — the map starts empty, and hydration fills it. */
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Reading ──────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The value the user actually chose — the ONLY read. A default never stands in for silence here.
|
|
76
|
+
*
|
|
77
|
+
* There is deliberately no default-masking sibling. One existed, and every bug in this area came
|
|
78
|
+
* from a caller taking the masked value for a chosen one: a form prefilled with `"gpt-4o"` and
|
|
79
|
+
* saved it, so an install whose owner had configured a different provider was left holding
|
|
80
|
+
* `jx.ai.model: "gpt-4o"` and nothing else. A consumer that needs a fallback composes it where the
|
|
81
|
+
* fallbacks are known — see `ai-models.ts`'s `preferredModel`, which asks the backend what it
|
|
82
|
+
* prefers before reaching for a name invented here.
|
|
83
|
+
*
|
|
84
|
+
* @returns The stored value, or "" when none is stored.
|
|
85
|
+
*/
|
|
86
|
+
export function readStoredSetting(definition: SettingDefinition): string {
|
|
87
|
+
return values.get(definition.key) ?? "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether a value is stored — as distinct from stored-and-empty, or absent-with-a-default. */
|
|
91
|
+
export function hasSetting(definition: SettingDefinition): boolean {
|
|
92
|
+
return (values.get(definition.key) ?? "") !== "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Change notification ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const listeners = new Set<(keys: readonly string[]) => void>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Subscribe to settings changes, local or remote. Returns the unsubscribe.
|
|
101
|
+
*
|
|
102
|
+
* This is the seam that lets a surface hold no copy of a setting: the composer's model picker, the
|
|
103
|
+
* Accounts list and the New Project gates all read through the kernel and repaint on this, rather
|
|
104
|
+
* than each caching a value that another window can invalidate.
|
|
105
|
+
*/
|
|
106
|
+
export function onSettingsChanged(listener: (keys: readonly string[]) => void): () => void {
|
|
107
|
+
listeners.add(listener);
|
|
108
|
+
return () => {
|
|
109
|
+
listeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Announce the keys that moved. Never called with an empty list. */
|
|
114
|
+
function announce(keys: readonly string[]): void {
|
|
115
|
+
for (const listener of listeners) {
|
|
116
|
+
listener(keys);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Writing ──────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const queue = createWriteQueue({
|
|
123
|
+
onError(error: unknown) {
|
|
124
|
+
notify("error", "Your settings could not be saved.", {
|
|
125
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
126
|
+
key: "settings-write",
|
|
127
|
+
source: "Settings",
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
async send(patch: SettingsPatch) {
|
|
131
|
+
if (!hasPlatform()) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const platform = getPlatform();
|
|
135
|
+
if (!platform.patchSettings) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
/*
|
|
139
|
+
* Only the keys that actually moved go on the wire. A key this window never touched is one the
|
|
140
|
+
* backend leaves exactly as it found it — which is what stops a second window's settings being
|
|
141
|
+
* cleared by a window that happens to hold none of them.
|
|
142
|
+
*
|
|
143
|
+
* An empty value is sent as a REMOVAL. The store is what a fresh install reads back at boot, and
|
|
144
|
+
* `"jx.ai.model": ""` there would be a model choice rather than the absence of one.
|
|
145
|
+
*/
|
|
146
|
+
const set: Record<string, string> = {};
|
|
147
|
+
const remove: string[] = [];
|
|
148
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
149
|
+
if (value) {
|
|
150
|
+
set[key] = value;
|
|
151
|
+
} else {
|
|
152
|
+
remove.push(key);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
await platform.patchSettings({ remove, set });
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Resolves when every settings write made so far has been sent. */
|
|
160
|
+
export function settingsSettled(): Promise<void> {
|
|
161
|
+
return queue.settled();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Apply a patch: `null` deletes, a string stores. Keys named by neither are untouched.
|
|
166
|
+
*
|
|
167
|
+
* Returns the keys that actually moved, so a no-op write announces nothing and schedules nothing.
|
|
168
|
+
*/
|
|
169
|
+
function apply(patch: SettingsPatch): string[] {
|
|
170
|
+
const moved: string[] = [];
|
|
171
|
+
for (const [key, next] of Object.entries(patch)) {
|
|
172
|
+
const before = values.get(key) ?? null;
|
|
173
|
+
if (before === next) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (next === null) {
|
|
177
|
+
values.delete(key);
|
|
178
|
+
} else {
|
|
179
|
+
values.set(key, next);
|
|
180
|
+
}
|
|
181
|
+
writeCache(key, next);
|
|
182
|
+
moved.push(key);
|
|
183
|
+
}
|
|
184
|
+
return moved;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Store a value. An empty string is a VALUE; {@link clearSetting} is the only deletion. */
|
|
188
|
+
export function setSetting(definition: SettingDefinition, value: string): void {
|
|
189
|
+
const next = definition.normalize ? definition.normalize(value) : value;
|
|
190
|
+
const moved = apply({ [definition.key]: next });
|
|
191
|
+
if (moved.length > 0) {
|
|
192
|
+
queue.enqueue({ [definition.key]: next });
|
|
193
|
+
announce(moved);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Store several settings as one change — one announcement, one write.
|
|
199
|
+
*
|
|
200
|
+
* What a form's Save should call. Three separate `setSetting`s would coalesce into one write
|
|
201
|
+
* anyway, but they would announce three times, and each intermediate announcement describes a state
|
|
202
|
+
* the user never asked for.
|
|
203
|
+
*/
|
|
204
|
+
export function setSettings(entries: readonly [SettingDefinition, string][]): void {
|
|
205
|
+
const patch: SettingsPatch = {};
|
|
206
|
+
for (const [definition, value] of entries) {
|
|
207
|
+
patch[definition.key] = definition.normalize ? definition.normalize(value) : value;
|
|
208
|
+
}
|
|
209
|
+
const moved = apply(patch);
|
|
210
|
+
if (moved.length > 0) {
|
|
211
|
+
queue.enqueue(patch);
|
|
212
|
+
announce(moved);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Forget several settings as one change — the shape a Disconnect wants. */
|
|
217
|
+
export function clearSettings(definitions: readonly SettingDefinition[]): void {
|
|
218
|
+
const patch: SettingsPatch = {};
|
|
219
|
+
for (const definition of definitions) {
|
|
220
|
+
patch[definition.key] = null;
|
|
221
|
+
}
|
|
222
|
+
const moved = apply(patch);
|
|
223
|
+
if (moved.length > 0) {
|
|
224
|
+
queue.enqueue(patch);
|
|
225
|
+
announce(moved);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Hydration ────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reconcile with the backend store. Call once, after the platform is registered; a no-op on
|
|
233
|
+
* platforms without one (dev server, cloud), where the cache is all there is.
|
|
234
|
+
*
|
|
235
|
+
* Deliberately not awaited by the boot path — hydration must not block the first paint — which is
|
|
236
|
+
* safe because the Map is already serving cached values by then. Anything the backend holds wins;
|
|
237
|
+
* anything only this machine holds is pushed up once, so a user upgrading from a cache-only build
|
|
238
|
+
* does not lose what they had configured.
|
|
239
|
+
*/
|
|
240
|
+
export async function hydrateSettings(): Promise<void> {
|
|
241
|
+
if (!hasPlatform()) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const platform = getPlatform();
|
|
245
|
+
if (!platform.getSettings) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
let stored: Record<string, string>;
|
|
249
|
+
try {
|
|
250
|
+
stored = await platform.getSettings();
|
|
251
|
+
} catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const incoming: SettingsPatch = {};
|
|
255
|
+
let needsMigration = false;
|
|
256
|
+
for (const definition of USER_SETTINGS) {
|
|
257
|
+
const backendValue = stored[definition.key];
|
|
258
|
+
if (backendValue) {
|
|
259
|
+
incoming[definition.key] = backendValue;
|
|
260
|
+
} else if (values.get(definition.key)) {
|
|
261
|
+
needsMigration = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const moved = apply(incoming);
|
|
265
|
+
if (moved.length > 0) {
|
|
266
|
+
announce(moved);
|
|
267
|
+
}
|
|
268
|
+
if (needsMigration) {
|
|
269
|
+
/* Push this machine's cache-only values up, through the same queue as any other write so it
|
|
270
|
+
cannot race one the user is making at the same moment. Only the keys the backend lacks — a
|
|
271
|
+
patch says nothing about the rest, so hydration cannot echo back what it just read. */
|
|
272
|
+
const push: SettingsPatch = {};
|
|
273
|
+
for (const definition of USER_SETTINGS) {
|
|
274
|
+
const value = values.get(definition.key);
|
|
275
|
+
if (value && !stored[definition.key]) {
|
|
276
|
+
push[definition.key] = value;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
queue.enqueue(push);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Adopt the backend store as another window (or a hand-edit) left it.
|
|
285
|
+
*
|
|
286
|
+
* Applied and announced, but never written back: this is news, not an edit, and echoing it would
|
|
287
|
+
* have two windows writing the same value at each other. A key the store no longer holds is
|
|
288
|
+
* forgotten here too, so a Disconnect in one window reaches the others.
|
|
289
|
+
*
|
|
290
|
+
* Self-echo needs no filter. Our own write reaches us through the same watch, and by then the
|
|
291
|
+
* values already match — {@link apply} reports nothing moved, so nothing is announced.
|
|
292
|
+
*/
|
|
293
|
+
export function adoptRemoteSettings(stored: Record<string, string>): void {
|
|
294
|
+
const patch: SettingsPatch = {};
|
|
295
|
+
for (const definition of USER_SETTINGS) {
|
|
296
|
+
patch[definition.key] = stored[definition.key] ?? null;
|
|
297
|
+
}
|
|
298
|
+
const moved = apply(patch);
|
|
299
|
+
if (moved.length > 0) {
|
|
300
|
+
announce(moved);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Subscribe to the backend store, if this platform can push. A no-op where it cannot.
|
|
306
|
+
*
|
|
307
|
+
* Called once at boot. The returned unsubscribe is not used — Studio boots this module once per
|
|
308
|
+
* window and never unwinds it — so it is deliberately dropped rather than stored somewhere nothing
|
|
309
|
+
* would read it.
|
|
310
|
+
*/
|
|
311
|
+
export function watchRemoteSettings(): void {
|
|
312
|
+
if (!hasPlatform()) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
getPlatform().subscribeSettings?.(adoptRemoteSettings);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Drop every value this kernel holds, cache included.
|
|
320
|
+
*
|
|
321
|
+
* The `?profile=fresh` startup profile's half of the story: clearing `localStorage` alone left the
|
|
322
|
+
* Map — and the backend store — untouched, so "fresh" was not fresh. This does not write to the
|
|
323
|
+
* backend, because a clean profile must not destroy credentials that belong to the user rather than
|
|
324
|
+
* to the profile.
|
|
325
|
+
*/
|
|
326
|
+
export function resetSettings(): void {
|
|
327
|
+
const moved = [...values.keys()];
|
|
328
|
+
values.clear();
|
|
329
|
+
for (const key of moved) {
|
|
330
|
+
writeCache(key, null);
|
|
331
|
+
}
|
|
332
|
+
if (moved.length > 0) {
|
|
333
|
+
announce(moved);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A coalescing, serializing write queue.
|
|
3
|
+
*
|
|
4
|
+
* The defect it exists to make unrepresentable: saving a provider mutates three settings through
|
|
5
|
+
* three setters, and each setter used to fire its own whole-map write. Three overlapping writes went
|
|
6
|
+
* out against one file, each reading storage at a different moment, with no ordering between them —
|
|
7
|
+
* so whichever landed last won. That was observed in the wild as a settings file holding
|
|
8
|
+
* `{"jx.ai.model": "gpt-4o"}`: the SECOND of three snapshots, landing last, a state storage itself
|
|
9
|
+
* never rested in.
|
|
10
|
+
*
|
|
11
|
+
* Two rules, and they are different guarantees:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Coalesce.** Every mutation in one synchronous burst merges into a single patch, sent once
|
|
14
|
+
* the burst has finished. N setters produce one write, not N.
|
|
15
|
+
* 2. **Serialize.** At most one send is in flight. A mutation made while one is out waits for it
|
|
16
|
+
* rather than racing it, so the order the app made changes in is the order the store sees them.
|
|
17
|
+
*
|
|
18
|
+
* Generic over the transport so the kernel can hand it a platform call and a test can hand it an
|
|
19
|
+
* array. `schedule` is injectable for the same reason.
|
|
20
|
+
*
|
|
21
|
+
* @license MIT
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A set of changes to apply. `null` DELETES the key; a key named by neither map is left alone.
|
|
26
|
+
*
|
|
27
|
+
* That is the whole difference from the whole-map replace this supersedes: a writer that says
|
|
28
|
+
* nothing about a key cannot destroy it, so a window with a stale or empty view of settings can no
|
|
29
|
+
* longer clear a sibling window's.
|
|
30
|
+
*/
|
|
31
|
+
export type SettingsPatch = Record<string, string | null>;
|
|
32
|
+
|
|
33
|
+
export interface WriteQueueOptions {
|
|
34
|
+
/** Deliver one merged patch. Rejection is reported through {@link WriteQueueOptions.onError}. */
|
|
35
|
+
send: (patch: SettingsPatch) => Promise<void>;
|
|
36
|
+
/** Called with the failure and the patch that did not land. */
|
|
37
|
+
onError?: (error: unknown, patch: SettingsPatch) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Defer to the end of the current burst. Defaults to `queueMicrotask`.
|
|
40
|
+
*
|
|
41
|
+
* A microtask rather than a timer: the burst is synchronous, so the patch goes out in the same
|
|
42
|
+
* turn the user acted in, and nothing can observe a half-applied save.
|
|
43
|
+
*/
|
|
44
|
+
schedule?: (run: () => void) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WriteQueue {
|
|
48
|
+
/** Merge `patch` into the pending one, scheduling a send if none is pending. */
|
|
49
|
+
enqueue: (patch: SettingsPatch) => void;
|
|
50
|
+
/** Resolves when everything enqueued so far has been sent (or has failed and been reported). */
|
|
51
|
+
settled: () => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a queue over `send`.
|
|
56
|
+
*
|
|
57
|
+
* @param {WriteQueueOptions} opts
|
|
58
|
+
* @returns {WriteQueue}
|
|
59
|
+
*/
|
|
60
|
+
export function createWriteQueue(opts: WriteQueueOptions): WriteQueue {
|
|
61
|
+
const schedule = opts.schedule ?? queueMicrotask;
|
|
62
|
+
|
|
63
|
+
/** Merged changes not yet handed to `send`; null when nothing is waiting. */
|
|
64
|
+
let pending: SettingsPatch | null = null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The chain every send is appended to.
|
|
68
|
+
*
|
|
69
|
+
* Always resolved — each link ends in a handler — so one failed write cannot poison the writes
|
|
70
|
+
* queued behind it.
|
|
71
|
+
*/
|
|
72
|
+
let tail: Promise<void> = Promise.resolve();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves when the most recently scheduled burst has been sent.
|
|
76
|
+
*
|
|
77
|
+
* Tracked explicitly rather than inferred from {@link tail}: a caller that enqueues and then
|
|
78
|
+
* awaits would otherwise be awaiting the tail as it stood BEFORE its own patch joined it, and
|
|
79
|
+
* whether that happens to work depends on how many turns the scheduler takes.
|
|
80
|
+
*/
|
|
81
|
+
let idle: Promise<void> = Promise.resolve();
|
|
82
|
+
|
|
83
|
+
/** Hand the merged burst to `send`, appended to the chain. */
|
|
84
|
+
function flush(): Promise<void> {
|
|
85
|
+
const patch = pending;
|
|
86
|
+
pending = null;
|
|
87
|
+
if (!patch) {
|
|
88
|
+
return tail;
|
|
89
|
+
}
|
|
90
|
+
tail = tail
|
|
91
|
+
.then(() => opts.send(patch))
|
|
92
|
+
.catch((error: unknown) => {
|
|
93
|
+
opts.onError?.(error, patch);
|
|
94
|
+
});
|
|
95
|
+
return tail;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
enqueue(patch: SettingsPatch) {
|
|
100
|
+
if (pending) {
|
|
101
|
+
Object.assign(pending, patch);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
pending = { ...patch };
|
|
105
|
+
idle = new Promise<void>((resolve) => {
|
|
106
|
+
schedule(() => {
|
|
107
|
+
void flush().then(resolve);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
settled() {
|
|
113
|
+
return idle;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -20,15 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { clearGithubToken, githubTokenLocation, githubTokenStored } from "../github/github-auth";
|
|
23
|
-
import { getCfAccountId, getCfToken
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
getModel,
|
|
27
|
-
hasOpenAiKey,
|
|
28
|
-
setBaseUrl,
|
|
29
|
-
setModel,
|
|
30
|
-
setOpenAiKey,
|
|
31
|
-
} from "../services/ai-settings";
|
|
23
|
+
import { clearCfConnection, getCfAccountId, getCfToken } from "../services/cf-settings";
|
|
24
|
+
import { clearAiProvider, getBaseUrl, hasOpenAiKey } from "../services/ai-settings";
|
|
25
|
+
import { preferredModel } from "../services/ai-models";
|
|
32
26
|
|
|
33
27
|
/** One stored credential, as the Accounts section renders it. */
|
|
34
28
|
export interface AccountRecord {
|
|
@@ -53,7 +47,7 @@ export function listAccounts(): AccountRecord[] {
|
|
|
53
47
|
const githubToken = githubTokenStored();
|
|
54
48
|
const cfToken = getCfToken();
|
|
55
49
|
const cfAccount = getCfAccountId();
|
|
56
|
-
const model =
|
|
50
|
+
const model = preferredModel();
|
|
57
51
|
const endpoint = getBaseUrl();
|
|
58
52
|
return [
|
|
59
53
|
{
|
|
@@ -74,11 +68,7 @@ export function listAccounts(): AccountRecord[] {
|
|
|
74
68
|
? `Key stored. Model ${model}${endpoint ? ` via ${endpoint}` : ""}.`
|
|
75
69
|
: "No key stored. The Assistant section is where one is entered.",
|
|
76
70
|
connected: hasOpenAiKey(),
|
|
77
|
-
revoke:
|
|
78
|
-
setOpenAiKey("");
|
|
79
|
-
setBaseUrl("");
|
|
80
|
-
setModel("");
|
|
81
|
-
},
|
|
71
|
+
revoke: clearAiProvider,
|
|
82
72
|
},
|
|
83
73
|
{
|
|
84
74
|
id: "cloudflare",
|
|
@@ -87,10 +77,7 @@ export function listAccounts(): AccountRecord[] {
|
|
|
87
77
|
? `Connected${cfAccount ? ` — account ${cfAccount}` : ""}.`
|
|
88
78
|
: "Not connected. Publishing will ask when it needs it.",
|
|
89
79
|
connected: Boolean(cfToken),
|
|
90
|
-
revoke:
|
|
91
|
-
setCfToken("");
|
|
92
|
-
setCfAccountId("");
|
|
93
|
-
},
|
|
80
|
+
revoke: clearCfConnection,
|
|
94
81
|
},
|
|
95
82
|
];
|
|
96
83
|
}
|