@jxsuite/studio 0.35.0 → 0.37.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/iframe-entry.js +18 -9
- package/dist/iframe-entry.js.map +4 -4
- package/dist/studio.js +12278 -2990
- package/dist/studio.js.map +73 -21
- package/package.json +10 -5
- package/src/canvas/canvas-render.ts +85 -12
- package/src/canvas/iframe-host.ts +85 -0
- package/src/canvas/iframe-overlay.ts +30 -1
- package/src/collab/collab-session.ts +903 -0
- package/src/collab/collab-state.ts +71 -0
- package/src/collab/monaco-cursors.ts +69 -0
- package/src/collab/presence-chips.ts +58 -0
- package/src/files/file-ops.ts +6 -0
- package/src/files/files.ts +6 -0
- package/src/panels/ai-panel.ts +25 -3
- package/src/panels/git-panel.ts +5 -0
- package/src/panels/toolbar.ts +15 -3
- package/src/panels/welcome-screen.ts +26 -0
- package/src/platforms/cloud.ts +805 -0
- package/src/platforms/devserver.ts +107 -0
- package/src/project-list.ts +38 -0
- package/src/publish/pages-service.ts +186 -0
- package/src/publish/publish-panel.ts +360 -0
- package/src/services/ai-models.ts +30 -1
- package/src/services/cf-settings.ts +58 -0
- package/src/services/settings-store.ts +7 -1
- package/src/studio.ts +25 -0
- package/src/tabs/doc-op-apply.ts +5 -81
- package/src/tabs/patch-ops.ts +7 -23
- package/src/tabs/transact.ts +138 -12
- package/src/types.ts +95 -174
- package/src/workspace/workspace.ts +4 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Publish panel — the one-click Cloudflare Pages publish flow, driven by the
|
|
4
|
+
* PAL's cf* members. States: unsupported platform → info; no credential →
|
|
5
|
+
* connect (hosted OAuth via cfConnect, or an API-token form backed by
|
|
6
|
+
* cf-settings); connected without `build.deploy` → create-and-connect form;
|
|
7
|
+
* connected → deployment status (publishing rides every commit).
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { html } from "lit-html";
|
|
13
|
+
import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
|
|
14
|
+
import { getPlatform } from "../platform";
|
|
15
|
+
import { getCfToken, setCfToken } from "../services/cf-settings";
|
|
16
|
+
import { projectState } from "../store";
|
|
17
|
+
import type { CfConnection } from "../types";
|
|
18
|
+
import { openModal } from "../ui/layers";
|
|
19
|
+
import type { CfAccount, PagesDeploymentInfo } from "./pages-service";
|
|
20
|
+
import {
|
|
21
|
+
connectDeploy,
|
|
22
|
+
latestDeployment,
|
|
23
|
+
listAccounts,
|
|
24
|
+
platformSupportsPublish,
|
|
25
|
+
writeDeployConfig,
|
|
26
|
+
} from "./pages-service";
|
|
27
|
+
|
|
28
|
+
const PAGES_APP_INSTALL_URL = "https://github.com/apps/cloudflare-pages/installations/new";
|
|
29
|
+
|
|
30
|
+
let _handle: ReturnType<typeof openModal> | null = null;
|
|
31
|
+
let _connection: CfConnection | null | "loading" = "loading";
|
|
32
|
+
let _accounts: CfAccount[] = [];
|
|
33
|
+
let _deployment: PagesDeploymentInfo | null = null;
|
|
34
|
+
let _error = "";
|
|
35
|
+
let _busy = false;
|
|
36
|
+
let _form = { accountId: "", branch: "main", owner: "", projectName: "", repo: "" };
|
|
37
|
+
|
|
38
|
+
function currentConfig(): ProjectConfig | null {
|
|
39
|
+
return (projectState?.projectConfig as ProjectConfig | undefined) ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function currentDeploy(): DeployConfig | undefined {
|
|
43
|
+
return currentConfig()?.build?.deploy;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deriveSlug(name: string): string {
|
|
47
|
+
return (
|
|
48
|
+
name
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
51
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
52
|
+
.slice(0, 58) || "site"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prefill owner/repo from cloud roots ("owner/repo"); blank elsewhere. */
|
|
57
|
+
function prefillRepo(): { owner: string; repo: string } {
|
|
58
|
+
const root = getPlatform().projectRoot;
|
|
59
|
+
const match = /^([\w.-]+)\/([\w.-]+)$/.exec(root);
|
|
60
|
+
return match ? { owner: match[1]!, repo: match[2]! } : { owner: "", repo: "" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadConnection(): Promise<void> {
|
|
64
|
+
_connection = "loading";
|
|
65
|
+
render();
|
|
66
|
+
try {
|
|
67
|
+
_connection = (await getPlatform().cfConnection?.()) ?? null;
|
|
68
|
+
if (_connection?.connected) {
|
|
69
|
+
_accounts = await listAccounts();
|
|
70
|
+
_form.accountId = _connection.accountId ?? _accounts[0]?.id ?? "";
|
|
71
|
+
const deploy = currentDeploy();
|
|
72
|
+
if (deploy) {
|
|
73
|
+
_deployment = await latestDeployment(deploy);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
_connection = null;
|
|
78
|
+
_error = error instanceof Error ? error.message : String(error);
|
|
79
|
+
}
|
|
80
|
+
render();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function saveToken(host: HTMLElement): Promise<void> {
|
|
84
|
+
const input = host.querySelector<HTMLInputElement>("#cf-token-input");
|
|
85
|
+
setCfToken(input?.value ?? "");
|
|
86
|
+
_error = "";
|
|
87
|
+
await loadConnection();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function hostedConnect(): Promise<void> {
|
|
91
|
+
_busy = true;
|
|
92
|
+
render();
|
|
93
|
+
try {
|
|
94
|
+
await getPlatform().cfConnect?.();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
_error = error instanceof Error ? error.message : String(error);
|
|
97
|
+
}
|
|
98
|
+
_busy = false;
|
|
99
|
+
await loadConnection();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function submitConnect(): Promise<void> {
|
|
103
|
+
const config = currentConfig();
|
|
104
|
+
if (!config) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!_form.projectName || !_form.owner || !_form.repo || !_form.accountId) {
|
|
108
|
+
_error = "Account, project name, and the GitHub owner/repo are all required.";
|
|
109
|
+
render();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
_busy = true;
|
|
113
|
+
_error = "";
|
|
114
|
+
render();
|
|
115
|
+
try {
|
|
116
|
+
const deploy = await connectDeploy(config, {
|
|
117
|
+
accountId: _form.accountId,
|
|
118
|
+
owner: _form.owner,
|
|
119
|
+
productionBranch: _form.branch || "main",
|
|
120
|
+
projectName: _form.projectName,
|
|
121
|
+
repo: _form.repo,
|
|
122
|
+
});
|
|
123
|
+
_deployment = await latestDeployment(deploy).catch(() => null);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
_error = error instanceof Error ? error.message : String(error);
|
|
126
|
+
}
|
|
127
|
+
_busy = false;
|
|
128
|
+
render();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function disconnect(): Promise<void> {
|
|
132
|
+
const config = currentConfig();
|
|
133
|
+
if (!config) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
_busy = true;
|
|
137
|
+
render();
|
|
138
|
+
try {
|
|
139
|
+
await writeDeployConfig(config, null);
|
|
140
|
+
_deployment = null;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
_error = error instanceof Error ? error.message : String(error);
|
|
143
|
+
}
|
|
144
|
+
_busy = false;
|
|
145
|
+
render();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function close(): void {
|
|
149
|
+
_handle?.close();
|
|
150
|
+
_handle = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fieldRow(label: string, input: unknown) {
|
|
154
|
+
return html`
|
|
155
|
+
<label class="publish-field">
|
|
156
|
+
<span>${label}</span>
|
|
157
|
+
${input}
|
|
158
|
+
</label>
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function credentialTpl() {
|
|
163
|
+
const platform = getPlatform();
|
|
164
|
+
if (platform.cfConnect) {
|
|
165
|
+
return html`
|
|
166
|
+
<p>Connect your Cloudflare account to publish this site.</p>
|
|
167
|
+
<sp-button ?disabled=${_busy} @click=${() => void hostedConnect()}>
|
|
168
|
+
Connect Cloudflare
|
|
169
|
+
</sp-button>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
return html`
|
|
173
|
+
<p>
|
|
174
|
+
Paste a Cloudflare API token (permissions: Account Settings Read, Pages Read/Write). It is
|
|
175
|
+
stored locally and only sent to the same-origin proxy.
|
|
176
|
+
</p>
|
|
177
|
+
${fieldRow(
|
|
178
|
+
"API token",
|
|
179
|
+
html`<sp-textfield
|
|
180
|
+
id="cf-token-input"
|
|
181
|
+
type="password"
|
|
182
|
+
value=${getCfToken()}
|
|
183
|
+
placeholder="cf_..."
|
|
184
|
+
></sp-textfield>`,
|
|
185
|
+
)}
|
|
186
|
+
<sp-button ?disabled=${_busy} @click=${(e: Event) => void saveToken(hostOf(e))}>
|
|
187
|
+
Verify & Connect
|
|
188
|
+
</sp-button>
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hostOf(e: Event): HTMLElement {
|
|
193
|
+
return (e.target as HTMLElement).closest(".publish-modal") ?? document.body;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function connectFormTpl() {
|
|
197
|
+
return html`
|
|
198
|
+
<p>
|
|
199
|
+
Create a Cloudflare Pages project connected to this repository. Every commit then builds and
|
|
200
|
+
publishes automatically (<code>bunx jx build</code>).
|
|
201
|
+
</p>
|
|
202
|
+
${fieldRow(
|
|
203
|
+
"Account",
|
|
204
|
+
html`
|
|
205
|
+
<sp-picker
|
|
206
|
+
value=${_form.accountId}
|
|
207
|
+
@change=${(e: Event) => {
|
|
208
|
+
_form.accountId = (e.target as HTMLInputElement).value;
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
${_accounts.map((a) => html`<sp-menu-item value=${a.id}>${a.name}</sp-menu-item>`)}
|
|
212
|
+
</sp-picker>
|
|
213
|
+
`,
|
|
214
|
+
)}
|
|
215
|
+
${fieldRow(
|
|
216
|
+
"Pages project name",
|
|
217
|
+
html`<sp-textfield
|
|
218
|
+
value=${_form.projectName}
|
|
219
|
+
@input=${(e: Event) => {
|
|
220
|
+
_form.projectName = (e.target as HTMLInputElement).value;
|
|
221
|
+
}}
|
|
222
|
+
></sp-textfield>`,
|
|
223
|
+
)}
|
|
224
|
+
${fieldRow(
|
|
225
|
+
"GitHub owner",
|
|
226
|
+
html`<sp-textfield
|
|
227
|
+
value=${_form.owner}
|
|
228
|
+
@input=${(e: Event) => {
|
|
229
|
+
_form.owner = (e.target as HTMLInputElement).value;
|
|
230
|
+
}}
|
|
231
|
+
></sp-textfield>`,
|
|
232
|
+
)}
|
|
233
|
+
${fieldRow(
|
|
234
|
+
"GitHub repository",
|
|
235
|
+
html`<sp-textfield
|
|
236
|
+
value=${_form.repo}
|
|
237
|
+
@input=${(e: Event) => {
|
|
238
|
+
_form.repo = (e.target as HTMLInputElement).value;
|
|
239
|
+
}}
|
|
240
|
+
></sp-textfield>`,
|
|
241
|
+
)}
|
|
242
|
+
${fieldRow(
|
|
243
|
+
"Production branch",
|
|
244
|
+
html`<sp-textfield
|
|
245
|
+
value=${_form.branch}
|
|
246
|
+
@input=${(e: Event) => {
|
|
247
|
+
_form.branch = (e.target as HTMLInputElement).value;
|
|
248
|
+
}}
|
|
249
|
+
></sp-textfield>`,
|
|
250
|
+
)}
|
|
251
|
+
<sp-button ?disabled=${_busy} @click=${() => void submitConnect()}>
|
|
252
|
+
${_busy ? "Connecting…" : "Create & Connect"}
|
|
253
|
+
</sp-button>
|
|
254
|
+
`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function statusTpl(deploy: DeployConfig) {
|
|
258
|
+
return html`
|
|
259
|
+
<p>
|
|
260
|
+
Connected to Pages project <strong>${deploy.projectName}</strong>
|
|
261
|
+
${deploy.productionUrl
|
|
262
|
+
? html` —
|
|
263
|
+
<a href=${deploy.productionUrl} target="_blank" rel="noreferrer">
|
|
264
|
+
${deploy.productionUrl}
|
|
265
|
+
</a>`
|
|
266
|
+
: ""}
|
|
267
|
+
</p>
|
|
268
|
+
${_deployment
|
|
269
|
+
? html`
|
|
270
|
+
<p>
|
|
271
|
+
Latest deployment: <strong>${_deployment.stage}: ${_deployment.status}</strong>
|
|
272
|
+
(${_deployment.environment}) —
|
|
273
|
+
<a href=${_deployment.url} target="_blank" rel="noreferrer">preview</a>
|
|
274
|
+
</p>
|
|
275
|
+
`
|
|
276
|
+
: html`<p>No deployments yet — the first commit after connecting triggers one.</p>`}
|
|
277
|
+
<p class="publish-hint">Publishing happens automatically on every commit.</p>
|
|
278
|
+
<div class="publish-actions">
|
|
279
|
+
<sp-button variant="secondary" ?disabled=${_busy} @click=${() => void loadConnection()}>
|
|
280
|
+
Refresh
|
|
281
|
+
</sp-button>
|
|
282
|
+
<sp-button variant="negative" ?disabled=${_busy} @click=${() => void disconnect()}>
|
|
283
|
+
Disconnect
|
|
284
|
+
</sp-button>
|
|
285
|
+
</div>
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function bodyTpl() {
|
|
290
|
+
if (!platformSupportsPublish()) {
|
|
291
|
+
return html`
|
|
292
|
+
<p>
|
|
293
|
+
This platform cannot reach the Cloudflare API. Publish by committing and pushing — your host
|
|
294
|
+
builds <code>bunx jx build</code> and serves <code>dist/</code>.
|
|
295
|
+
</p>
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
if (_connection === "loading") {
|
|
299
|
+
return html`<p>Checking Cloudflare connection…</p>`;
|
|
300
|
+
}
|
|
301
|
+
if (!_connection?.connected) {
|
|
302
|
+
return credentialTpl();
|
|
303
|
+
}
|
|
304
|
+
const deploy = currentDeploy();
|
|
305
|
+
return deploy ? statusTpl(deploy) : connectFormTpl();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function errorTpl() {
|
|
309
|
+
if (!_error) {
|
|
310
|
+
return "";
|
|
311
|
+
}
|
|
312
|
+
const needsPagesApp = /github/i.test(_error) && /app|install|source|repo/i.test(_error);
|
|
313
|
+
return html`
|
|
314
|
+
<p class="publish-error">
|
|
315
|
+
${_error}
|
|
316
|
+
${needsPagesApp
|
|
317
|
+
? html` — if the Cloudflare Pages GitHub App is not installed on the repository,
|
|
318
|
+
<a href=${PAGES_APP_INSTALL_URL} target="_blank" rel="noreferrer">install it</a> and
|
|
319
|
+
retry.`
|
|
320
|
+
: ""}
|
|
321
|
+
</p>
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function render(): void {
|
|
326
|
+
const tpl = html`
|
|
327
|
+
<div class="new-project-modal publish-modal">
|
|
328
|
+
<div class="new-project-modal-header">
|
|
329
|
+
<h2 class="new-project-modal-title">Publish</h2>
|
|
330
|
+
<sp-action-button size="s" quiet @click=${close}>✕</sp-action-button>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="new-project-modal-body">${bodyTpl()} ${errorTpl()}</div>
|
|
333
|
+
</div>
|
|
334
|
+
`;
|
|
335
|
+
if (_handle) {
|
|
336
|
+
_handle.update(tpl);
|
|
337
|
+
} else {
|
|
338
|
+
_handle = openModal(tpl);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Open the publish modal for the active project. */
|
|
343
|
+
export function openPublishPanel(): void {
|
|
344
|
+
const config = currentConfig();
|
|
345
|
+
const { owner, repo } = prefillRepo();
|
|
346
|
+
_connection = "loading";
|
|
347
|
+
_accounts = [];
|
|
348
|
+
_deployment = null;
|
|
349
|
+
_error = "";
|
|
350
|
+
_busy = false;
|
|
351
|
+
_form = {
|
|
352
|
+
accountId: "",
|
|
353
|
+
branch: "main",
|
|
354
|
+
owner,
|
|
355
|
+
projectName: currentDeploy()?.projectName ?? deriveSlug(config?.name ?? ""),
|
|
356
|
+
repo,
|
|
357
|
+
};
|
|
358
|
+
render();
|
|
359
|
+
void loadConnection();
|
|
360
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* @license MIT
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { AiModelsResponse } from "@jxsuite/protocol";
|
|
14
15
|
import { getPlatform } from "../platform";
|
|
15
16
|
import { getBaseUrl, getOpenAiKey } from "./ai-settings";
|
|
16
17
|
|
|
@@ -20,10 +21,35 @@ export interface AiModel {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
let cache: AiModel[] | null = null;
|
|
24
|
+
let proxyConfigured = false;
|
|
25
|
+
let proxyManaged = false;
|
|
26
|
+
let proxyDefaultModel = "";
|
|
23
27
|
|
|
24
28
|
/** Drop the cached model list (call after credentials/endpoint changes). */
|
|
25
29
|
export function invalidateModelCache() {
|
|
26
30
|
cache = null;
|
|
31
|
+
proxyConfigured = false;
|
|
32
|
+
proxyManaged = false;
|
|
33
|
+
proxyDefaultModel = "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the proxy reported itself configured on the last fetch — true when the backend holds
|
|
38
|
+
* credentials (managed platforms, OPENAI_API_KEY env), so the assistant works without a locally
|
|
39
|
+
* stored key.
|
|
40
|
+
*/
|
|
41
|
+
export function isProxyConfigured(): boolean {
|
|
42
|
+
return proxyConfigured;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the platform manages AI credentials itself (cloud Workers AI). */
|
|
46
|
+
export function isManagedProxy(): boolean {
|
|
47
|
+
return proxyManaged;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The proxy's preferred model id ("" when it does not declare one). */
|
|
51
|
+
export function getProxyDefaultModel(): string {
|
|
52
|
+
return proxyDefaultModel;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
55
|
/**
|
|
@@ -58,7 +84,10 @@ export async function fetchAvailableModels(
|
|
|
58
84
|
if (!resp.ok) {
|
|
59
85
|
throw new Error(`HTTP ${resp.status}`);
|
|
60
86
|
}
|
|
61
|
-
const data = (await resp.json()) as
|
|
87
|
+
const data = (await resp.json()) as Partial<AiModelsResponse>;
|
|
88
|
+
proxyConfigured = data.configured === true;
|
|
89
|
+
proxyManaged = data.managed === true;
|
|
90
|
+
proxyDefaultModel = data.defaultModel ?? "";
|
|
62
91
|
cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
|
|
63
92
|
return cache;
|
|
64
93
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Cf-settings — local persistence for the Cloudflare publish connection on
|
|
4
|
+
* platforms without a hosted OAuth broker (dev server, desktop). The API
|
|
5
|
+
* token is stored like the AI key: localStorage first, mirrored through the
|
|
6
|
+
* platform settings backend where one exists. It only ever leaves the machine
|
|
7
|
+
* to the same-origin `/__studio/cf/proxy`, which forwards it as a bearer to
|
|
8
|
+
* api.cloudflare.com (not CORS-enabled, hence the proxy).
|
|
9
|
+
*
|
|
10
|
+
* @license MIT
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { persistSettings } from "./settings-store";
|
|
14
|
+
|
|
15
|
+
const TOKEN_STORAGE = "jx.cf.token";
|
|
16
|
+
const ACCOUNT_STORAGE = "jx.cf.accountId";
|
|
17
|
+
|
|
18
|
+
function read(key: string): string {
|
|
19
|
+
try {
|
|
20
|
+
return globalThis.localStorage?.getItem(key) ?? "";
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function write(key: string, value: string): void {
|
|
27
|
+
try {
|
|
28
|
+
const trimmed = (value || "").trim();
|
|
29
|
+
if (trimmed) {
|
|
30
|
+
globalThis.localStorage?.setItem(key, trimmed);
|
|
31
|
+
} else {
|
|
32
|
+
globalThis.localStorage?.removeItem(key);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Storage unavailable (private mode) — the connection just won't persist.
|
|
36
|
+
}
|
|
37
|
+
persistSettings();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The stored Cloudflare API token, or "" when not connected. */
|
|
41
|
+
export function getCfToken(): string {
|
|
42
|
+
return read(TOKEN_STORAGE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Persist (or clear, with a blank value) the Cloudflare API token. */
|
|
46
|
+
export function setCfToken(token: string): void {
|
|
47
|
+
write(TOKEN_STORAGE, token);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The selected Cloudflare account id, or "" when none chosen yet. */
|
|
51
|
+
export function getCfAccountId(): string {
|
|
52
|
+
return read(ACCOUNT_STORAGE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Persist (or clear) the selected Cloudflare account id. */
|
|
56
|
+
export function setCfAccountId(accountId: string): void {
|
|
57
|
+
write(ACCOUNT_STORAGE, accountId);
|
|
58
|
+
}
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
import { getPlatform, hasPlatform } from "../platform";
|
|
15
15
|
|
|
16
16
|
/** The localStorage keys mirrored into the platform's user-settings store. */
|
|
17
|
-
export const PERSISTED_SETTINGS_KEYS = [
|
|
17
|
+
export const PERSISTED_SETTINGS_KEYS = [
|
|
18
|
+
"jx.ai.openaiKey",
|
|
19
|
+
"jx.ai.baseUrl",
|
|
20
|
+
"jx.ai.model",
|
|
21
|
+
"jx.cf.token",
|
|
22
|
+
"jx.cf.accountId",
|
|
23
|
+
] as const;
|
|
18
24
|
|
|
19
25
|
/** Read a localStorage value defensively, treating unavailable/throwing storage as empty. */
|
|
20
26
|
function readLocal(key: string): string {
|
package/src/studio.ts
CHANGED
|
@@ -77,6 +77,11 @@ import {
|
|
|
77
77
|
setupTreeKeyboard,
|
|
78
78
|
} from "./files/files";
|
|
79
79
|
import { startFsSync } from "./files/fs-events";
|
|
80
|
+
import {
|
|
81
|
+
configureCollabNotifier,
|
|
82
|
+
configureCollabParser,
|
|
83
|
+
configureCollabSerializer,
|
|
84
|
+
} from "./collab/collab-session";
|
|
80
85
|
import { renderImportsTemplate } from "./panels/imports-panel";
|
|
81
86
|
import { renderHeadTemplate } from "./panels/head-panel";
|
|
82
87
|
import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
|
|
@@ -126,6 +131,7 @@ import {
|
|
|
126
131
|
} from "./panels/block-action-bar";
|
|
127
132
|
import { initCssData } from "./panels/style-utils";
|
|
128
133
|
import { initQuickSearch } from "./panels/quick-search";
|
|
134
|
+
import { hydrateProjectList } from "./project-list";
|
|
129
135
|
import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
|
|
130
136
|
import { hydrateSettings } from "./services/settings-store";
|
|
131
137
|
import { initWelcome } from "./panels/welcome-screen";
|
|
@@ -570,6 +576,18 @@ function safeRenderRightPanel() {
|
|
|
570
576
|
// Now that renderers are registered, bootstrap
|
|
571
577
|
registerFunctionCompletions();
|
|
572
578
|
|
|
579
|
+
// Collab sessions serialize/parse through the format host when mirroring between the structure
|
|
580
|
+
// Tree and the shared source text, and surface freezes via the status bar.
|
|
581
|
+
configureCollabSerializer(serializeDocument);
|
|
582
|
+
configureCollabParser(async (tab, text) => {
|
|
583
|
+
if (tab.documentPath && formatForPath(tab.documentPath)) {
|
|
584
|
+
const parsed = await parseSourceForPath(tab.documentPath, text);
|
|
585
|
+
return { document: parsed.document as JxMutableNode, frontmatter: parsed.frontmatter };
|
|
586
|
+
}
|
|
587
|
+
return { document: JSON.parse(text) as JxMutableNode };
|
|
588
|
+
});
|
|
589
|
+
configureCollabNotifier(statusMessage);
|
|
590
|
+
|
|
573
591
|
let fsUnsub: (() => void) | null = null;
|
|
574
592
|
/** (Re)subscribe the sidebar to backend filesystem events for the active project. */
|
|
575
593
|
function ensureFsSync() {
|
|
@@ -744,6 +762,13 @@ void hydrateRecentProjects().then(() => {
|
|
|
744
762
|
render();
|
|
745
763
|
});
|
|
746
764
|
|
|
765
|
+
// Hydrate the platform's project catalogue (dev server sites, cloud projects), then refresh the
|
|
766
|
+
// Welcome screen, which reads it synchronously. No-op on platforms without listProjects.
|
|
767
|
+
// oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
|
|
768
|
+
void hydrateProjectList().then(() => {
|
|
769
|
+
render();
|
|
770
|
+
});
|
|
771
|
+
|
|
747
772
|
// Hydrate user settings (AI connection parameters) from the backend store, then re-render so
|
|
748
773
|
// Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
|
|
749
774
|
// oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
|
package/src/tabs/doc-op-apply.ts
CHANGED
|
@@ -4,86 +4,10 @@
|
|
|
4
4
|
* from live editing: history replay in the parent ({@link file://./transact.ts}) and the iframe
|
|
5
5
|
* canvas's non-reactive shadow doc (the patch source-of-truth across the cross-origin bridge).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
7
|
+
* The implementation is the canonical one in `@jxsuite/collab/ops` (yjs-free, so the slim
|
|
8
|
+
* canvas-iframe bundle imports it without dragging yjs in); this module re-exports it so both sides
|
|
9
|
+
* of the frame boundary — and the collab bridge — replay ops through one code path.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
15
|
-
|
|
16
|
-
/** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
|
|
17
|
-
function jsonClone<T>(value: T): T {
|
|
18
|
-
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
|
|
19
|
-
return JSON.parse(JSON.stringify(value)) as T;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
|
|
23
|
-
export function cloneValue<T>(v: T): T {
|
|
24
|
-
return v === undefined || v === null ? v : (jsonClone(v as object) as T);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
|
|
28
|
-
export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
|
|
29
|
-
// Defense-in-depth: a path that resolves to a children array (rather than a node) would
|
|
30
|
-
// Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
|
|
31
|
-
// Nothing renders. Callers must pass a node; fail loudly if they don't.
|
|
32
|
-
if (Array.isArray(node)) {
|
|
33
|
-
throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
|
|
34
|
-
}
|
|
35
|
-
if (!node.children) {
|
|
36
|
-
node.children = [];
|
|
37
|
-
}
|
|
38
|
-
if (!Array.isArray(node.children)) {
|
|
39
|
-
throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
|
|
40
|
-
}
|
|
41
|
-
return node.children;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
|
|
46
|
-
* aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
|
|
47
|
-
*/
|
|
48
|
-
export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
|
|
49
|
-
switch (op.op) {
|
|
50
|
-
case "set-key": {
|
|
51
|
-
const node = getNodeAtPath(doc, op.path);
|
|
52
|
-
if (!node) {
|
|
53
|
-
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
54
|
-
}
|
|
55
|
-
const target = node as Record<string, unknown>;
|
|
56
|
-
if (op.value === undefined) {
|
|
57
|
-
delete target[op.key];
|
|
58
|
-
} else {
|
|
59
|
-
target[op.key] = cloneValue(op.value);
|
|
60
|
-
}
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
case "insert-child": {
|
|
64
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
65
|
-
childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
case "remove-child": {
|
|
69
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
70
|
-
childArray(parent).splice(op.index, 1);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
case "set-child": {
|
|
74
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
75
|
-
childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
case "move-child": {
|
|
79
|
-
const fromParent = getNodeAtPath(doc, op.fromParentPath);
|
|
80
|
-
const toParent = getNodeAtPath(doc, op.toParentPath);
|
|
81
|
-
const [node] = childArray(fromParent).splice(op.fromIndex, 1);
|
|
82
|
-
childArray(toParent).splice(op.toIndex, 0, node!);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
default: {
|
|
86
|
-
throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
12
|
+
export { applyDocOpToDoc, childArray, cloneValue } from "@jxsuite/collab/ops";
|
|
13
|
+
export type { JxDocOp } from "@jxsuite/collab/ops";
|
package/src/tabs/patch-ops.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* document as the single source of truth.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// oxlint-disable-next-line unicorn/prefer-export-from -- JxDocOpPair is also used locally (TransactionRecord)
|
|
9
|
+
import type { JxDocOpPair } from "@jxsuite/collab/ops";
|
|
8
10
|
import type { JxPath } from "../state";
|
|
9
11
|
import type { Tab } from "./tab.js";
|
|
10
12
|
|
|
@@ -31,30 +33,12 @@ export type JxPatchOp =
|
|
|
31
33
|
/**
|
|
32
34
|
* Value-carrying document mutation, replayable in either direction. Mutators record a
|
|
33
35
|
* forward/inverse pair per change; history applies them for surgical undo/redo and for
|
|
34
|
-
* materializing states from checkpoints — without whole-document snapshots per edit.
|
|
36
|
+
* materializing states from checkpoints — without whole-document snapshots per edit. The definition
|
|
37
|
+
* is canonical in `@jxsuite/collab/ops` (the collab bridge mirrors the same ops into a shared
|
|
38
|
+
* Y.Doc); re-exported here so studio call sites keep their import path.
|
|
35
39
|
*/
|
|
36
|
-
export type JxDocOp
|
|
37
|
-
|
|
38
|
-
| { op: "set-key"; path: JxPath; key: string; value?: unknown }
|
|
39
|
-
/** Insert a node at parentPath.children[index]. */
|
|
40
|
-
| { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
|
|
41
|
-
/** Remove parentPath.children[index]. */
|
|
42
|
-
| { op: "remove-child"; parentPath: JxPath; index: number }
|
|
43
|
-
/** Replace parentPath.children[index] with node. */
|
|
44
|
-
| { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
|
|
45
|
-
/** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
|
|
46
|
-
| {
|
|
47
|
-
op: "move-child";
|
|
48
|
-
fromParentPath: JxPath;
|
|
49
|
-
fromIndex: number;
|
|
50
|
-
toParentPath: JxPath;
|
|
51
|
-
toIndex: number;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export interface JxDocOpPair {
|
|
55
|
-
forward: JxDocOp;
|
|
56
|
-
inverse: JxDocOp;
|
|
57
|
-
}
|
|
40
|
+
export type { JxDocOp } from "@jxsuite/collab/ops";
|
|
41
|
+
export type { JxDocOpPair };
|
|
58
42
|
|
|
59
43
|
/** Everything recorded during one transaction. */
|
|
60
44
|
export interface TransactionRecord {
|