@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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { streamImport } from "../services/import-client";
|
|
12
|
+
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
12
13
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
13
14
|
import type {
|
|
14
15
|
DirEntry,
|
|
@@ -46,6 +47,13 @@ interface SiteEntry {
|
|
|
46
47
|
*/
|
|
47
48
|
export function createDevServerPlatform() {
|
|
48
49
|
let _projectRoot = "";
|
|
50
|
+
/** Lazy /__studio/collab capability probe (null = not asked yet). */
|
|
51
|
+
let _collabProbe: Promise<boolean> | null = null;
|
|
52
|
+
/**
|
|
53
|
+
* One multiplexed collab socket per page; per-doc handles come from openDoc. Memoized as a
|
|
54
|
+
* promise so concurrent first opens share the connection instead of racing two sockets.
|
|
55
|
+
*/
|
|
56
|
+
let _collabConnection: Promise<WsCollabConnection> | null = null;
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
59
|
* Prefix a project-relative path with the active project root for server API calls.
|
|
@@ -390,6 +398,35 @@ export function createDevServerPlatform() {
|
|
|
390
398
|
};
|
|
391
399
|
},
|
|
392
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Realtime co-editing over the dev server's /__studio/collab endpoint (rooms keyed by
|
|
403
|
+
* server-root-relative path). Probes capability once — older servers without the endpoint
|
|
404
|
+
* degrade to solo editing; the wire client's evaluation defers behind the dynamic import until
|
|
405
|
+
* a doc opens.
|
|
406
|
+
*/
|
|
407
|
+
async collab(docPath: string) {
|
|
408
|
+
if (typeof WebSocket === "undefined" || typeof location === "undefined") {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (_collabProbe === null) {
|
|
412
|
+
_collabProbe = fetch("/__studio/collab")
|
|
413
|
+
.then((res) => res.ok)
|
|
414
|
+
.catch(() => false);
|
|
415
|
+
}
|
|
416
|
+
if (!(await _collabProbe)) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
_collabConnection ??= (async () => {
|
|
420
|
+
const { createWsCollabConnection } = await import("@jxsuite/collab/client");
|
|
421
|
+
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
422
|
+
return createWsCollabConnection({
|
|
423
|
+
url: `${scheme}://${location.host}/__studio/collab`,
|
|
424
|
+
});
|
|
425
|
+
})();
|
|
426
|
+
const connection = await _collabConnection;
|
|
427
|
+
return connection.openDoc(serverPath(docPath));
|
|
428
|
+
},
|
|
429
|
+
|
|
393
430
|
/** @param {string} _path */
|
|
394
431
|
async createDirectory(_path: string) {
|
|
395
432
|
// The server creates directories implicitly when writing files.
|
|
@@ -837,6 +874,76 @@ export function createDevServerPlatform() {
|
|
|
837
874
|
}
|
|
838
875
|
},
|
|
839
876
|
|
|
877
|
+
// ─── Cloudflare publish surface (token-backed; see services/cf-settings) ─
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Allowlisted Cloudflare API passthrough. The token comes from the client's cf-settings store
|
|
881
|
+
* and rides in a header to the same-origin proxy (api.cloudflare.com is not CORS-enabled).
|
|
882
|
+
*/
|
|
883
|
+
async cfApi(apiPath: string, init?: { method?: string; body?: unknown }) {
|
|
884
|
+
const { getCfToken } = await import("../services/cf-settings");
|
|
885
|
+
const token = getCfToken();
|
|
886
|
+
if (!token) {
|
|
887
|
+
throw new Error("No Cloudflare API token configured");
|
|
888
|
+
}
|
|
889
|
+
const res = await fetch("/__studio/cf/proxy", {
|
|
890
|
+
method: "POST",
|
|
891
|
+
headers: { "Content-Type": "application/json", "X-CF-Token": token },
|
|
892
|
+
body: JSON.stringify({ path: apiPath, method: init?.method ?? "GET", body: init?.body }),
|
|
893
|
+
});
|
|
894
|
+
const envelope = (await res.json()) as {
|
|
895
|
+
success?: boolean;
|
|
896
|
+
result?: unknown;
|
|
897
|
+
errors?: { message: string }[];
|
|
898
|
+
error?: string;
|
|
899
|
+
};
|
|
900
|
+
if (!res.ok || envelope.success === false) {
|
|
901
|
+
const message =
|
|
902
|
+
envelope.errors?.map((e) => e.message).join("; ") ?? envelope.error ?? res.statusText;
|
|
903
|
+
throw new Error(`Cloudflare API: ${message}`);
|
|
904
|
+
}
|
|
905
|
+
return envelope.result ?? envelope;
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
/** Verify the stored token by listing accounts; null when none/invalid. */
|
|
909
|
+
async cfConnection() {
|
|
910
|
+
const { getCfAccountId, getCfToken, setCfAccountId } =
|
|
911
|
+
await import("../services/cf-settings");
|
|
912
|
+
if (!getCfToken()) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
const accounts = (await this.cfApi?.("/accounts")) as { id: string; name: string }[];
|
|
917
|
+
if (!accounts?.length) {
|
|
918
|
+
return { connected: false };
|
|
919
|
+
}
|
|
920
|
+
const chosen = accounts.find((a) => a.id === getCfAccountId()) ?? accounts[0]!;
|
|
921
|
+
setCfAccountId(chosen.id);
|
|
922
|
+
return { connected: true, accountId: chosen.id, accountName: chosen.name };
|
|
923
|
+
} catch {
|
|
924
|
+
return { connected: false };
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
// ─── Project catalogue ──────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
/** Every site under the server root, from the /__studio/sites glob. */
|
|
931
|
+
async listProjects() {
|
|
932
|
+
const res = await fetch("/__studio/sites");
|
|
933
|
+
if (!res.ok) {
|
|
934
|
+
return [];
|
|
935
|
+
}
|
|
936
|
+
const sites = await readJson<SiteEntry[]>(res);
|
|
937
|
+
return sites.map((site) => {
|
|
938
|
+
const config = site.config as { name?: string } | null;
|
|
939
|
+
return {
|
|
940
|
+
name: config?.name || site.path.split("/").at(-1) || site.path,
|
|
941
|
+
root: site.path,
|
|
942
|
+
description: site.path,
|
|
943
|
+
};
|
|
944
|
+
});
|
|
945
|
+
},
|
|
946
|
+
|
|
840
947
|
// ─── AI Assistant (Stack B: OpenAI-compatible SSE proxy) ───────────────────
|
|
841
948
|
|
|
842
949
|
aiChatUrl() {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project catalogue — a synchronous render cache over the optional `platform.listProjects` PAL
|
|
3
|
+
* member (dev server sites, cloud platforms). Hydrated once at boot (studio.ts) like the
|
|
4
|
+
* recent-projects cache; the welcome screen reads it synchronously.
|
|
5
|
+
*/
|
|
6
|
+
import { getPlatform, hasPlatform } from "./platform";
|
|
7
|
+
import type { ProjectListEntry } from "./types";
|
|
8
|
+
|
|
9
|
+
let cache: ProjectListEntry[] = [];
|
|
10
|
+
|
|
11
|
+
/** True when the active platform exposes a project catalogue. */
|
|
12
|
+
export function platformListsProjects(): boolean {
|
|
13
|
+
return hasPlatform() && typeof getPlatform().listProjects === "function";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Refresh the cache from the platform; resolves to [] when unsupported or failing. */
|
|
17
|
+
export async function hydrateProjectList(): Promise<void> {
|
|
18
|
+
if (!platformListsProjects()) {
|
|
19
|
+
cache = [];
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
cache = (await getPlatform().listProjects?.()) ?? [];
|
|
24
|
+
} catch {
|
|
25
|
+
// Catalogue is progressive enhancement; a failed fetch leaves the section hidden.
|
|
26
|
+
cache = [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Synchronous snapshot for render paths (never awaits). */
|
|
31
|
+
export function getProjectList(): ProjectListEntry[] {
|
|
32
|
+
return cache;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reset seam for tests. */
|
|
36
|
+
export function resetProjectList(): void {
|
|
37
|
+
cache = [];
|
|
38
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Pages-service — Cloudflare Pages publish domain logic over the PAL's
|
|
4
|
+
* `cfApi` passthrough. Works identically on every platform that provides it:
|
|
5
|
+
* the dev server / desktop (user API token via cf-settings → /__studio/cf/proxy)
|
|
6
|
+
* and the cloud platform (hosted OAuth). Publish state is written to
|
|
7
|
+
* project.json `build.deploy` — it travels with the repo, so any Studio can
|
|
8
|
+
* tell whether the publish workflow already exists.
|
|
9
|
+
*
|
|
10
|
+
* @license MIT
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { updateWranglerConfig } from "@jxsuite/create/scaffold";
|
|
14
|
+
import type { DeployConfig, ProjectConfig } from "@jxsuite/schema/types";
|
|
15
|
+
import { getPlatform } from "../platform";
|
|
16
|
+
import { updateSiteConfig } from "../site-context";
|
|
17
|
+
|
|
18
|
+
export interface CfAccount {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PagesDeploymentInfo {
|
|
24
|
+
id: string;
|
|
25
|
+
url: string;
|
|
26
|
+
environment: string;
|
|
27
|
+
/** Latest stage name/status, e.g. "deploy: success" or "build: failure". */
|
|
28
|
+
stage: string;
|
|
29
|
+
status: string;
|
|
30
|
+
createdOn: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConnectOptions {
|
|
34
|
+
accountId: string;
|
|
35
|
+
projectName: string;
|
|
36
|
+
productionBranch: string;
|
|
37
|
+
owner: string;
|
|
38
|
+
repo: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when the active platform can reach the Cloudflare API. */
|
|
42
|
+
export function platformSupportsPublish(): boolean {
|
|
43
|
+
return typeof getPlatform().cfApi === "function";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function cfApi<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
|
|
47
|
+
const api = getPlatform().cfApi;
|
|
48
|
+
if (!api) {
|
|
49
|
+
throw new Error("This platform cannot reach the Cloudflare API");
|
|
50
|
+
}
|
|
51
|
+
return (await api(path, init)) as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function listAccounts(): Promise<CfAccount[]> {
|
|
55
|
+
return cfApi<CfAccount[]>("/accounts");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PagesProjectWire {
|
|
59
|
+
name: string;
|
|
60
|
+
subdomain?: string;
|
|
61
|
+
domains?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The Pages project, or null when it does not exist yet. */
|
|
65
|
+
export async function getPagesProject(
|
|
66
|
+
accountId: string,
|
|
67
|
+
projectName: string,
|
|
68
|
+
): Promise<PagesProjectWire | null> {
|
|
69
|
+
try {
|
|
70
|
+
return await cfApi<PagesProjectWire>(`/accounts/${accountId}/pages/projects/${projectName}`);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a GitHub-connected Pages project that builds `bunx jx build` on every push. Requires the
|
|
78
|
+
* Cloudflare Pages GitHub App on the repo — the characteristic failure is surfaced to the caller
|
|
79
|
+
* with an install hint.
|
|
80
|
+
*/
|
|
81
|
+
export async function createPagesProject(opts: ConnectOptions): Promise<PagesProjectWire> {
|
|
82
|
+
return cfApi<PagesProjectWire>(`/accounts/${opts.accountId}/pages/projects`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
body: {
|
|
85
|
+
name: opts.projectName,
|
|
86
|
+
production_branch: opts.productionBranch,
|
|
87
|
+
build_config: {
|
|
88
|
+
build_command: "bunx jx build",
|
|
89
|
+
destination_dir: "dist",
|
|
90
|
+
},
|
|
91
|
+
source: {
|
|
92
|
+
type: "github",
|
|
93
|
+
config: {
|
|
94
|
+
owner: opts.owner,
|
|
95
|
+
repo_name: opts.repo,
|
|
96
|
+
production_branch: opts.productionBranch,
|
|
97
|
+
deployments_enabled: true,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface DeploymentWire {
|
|
105
|
+
id: string;
|
|
106
|
+
url: string;
|
|
107
|
+
environment: string;
|
|
108
|
+
latest_stage?: { name?: string; status?: string };
|
|
109
|
+
created_on: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The most recent deployment of the connected project, or null when none. */
|
|
113
|
+
export async function latestDeployment(deploy: DeployConfig): Promise<PagesDeploymentInfo | null> {
|
|
114
|
+
const deployments = await cfApi<DeploymentWire[]>(
|
|
115
|
+
`/accounts/${deploy.accountId}/pages/projects/${deploy.projectName}/deployments`,
|
|
116
|
+
);
|
|
117
|
+
const [latest] = deployments;
|
|
118
|
+
if (!latest) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
id: latest.id,
|
|
123
|
+
url: latest.url,
|
|
124
|
+
environment: latest.environment,
|
|
125
|
+
stage: latest.latest_stage?.name ?? "unknown",
|
|
126
|
+
status: latest.latest_stage?.status ?? "unknown",
|
|
127
|
+
createdOn: latest.created_on,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Write (or with null, remove) the `build.deploy` block, preserving the rest of `build`. On
|
|
133
|
+
* connect, wrangler.jsonc is patched so its `name` matches the connected Pages project
|
|
134
|
+
* (comment-bearing JSONC is left alone).
|
|
135
|
+
*/
|
|
136
|
+
export async function writeDeployConfig(
|
|
137
|
+
config: ProjectConfig,
|
|
138
|
+
deploy: DeployConfig | null,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const build: NonNullable<ProjectConfig["build"]> = { ...config.build };
|
|
141
|
+
if (deploy === null) {
|
|
142
|
+
delete build.deploy;
|
|
143
|
+
} else {
|
|
144
|
+
build.deploy = deploy;
|
|
145
|
+
}
|
|
146
|
+
await updateSiteConfig({ build });
|
|
147
|
+
|
|
148
|
+
if (deploy !== null) {
|
|
149
|
+
const platform = getPlatform();
|
|
150
|
+
let existing: string | null = null;
|
|
151
|
+
try {
|
|
152
|
+
existing = await platform.readFile("wrangler.jsonc");
|
|
153
|
+
} catch {
|
|
154
|
+
existing = null;
|
|
155
|
+
}
|
|
156
|
+
const adapter =
|
|
157
|
+
build.adapter === "cloudflare-workers" ? "cloudflare-workers" : "cloudflare-pages";
|
|
158
|
+
const { content, patched } = updateWranglerConfig(existing, {
|
|
159
|
+
adapter,
|
|
160
|
+
slug: deploy.projectName,
|
|
161
|
+
});
|
|
162
|
+
if (patched) {
|
|
163
|
+
await platform.writeFile("wrangler.jsonc", content);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The whole connect flow: reuse the Pages project when it already exists, create it otherwise, then
|
|
170
|
+
* persist `build.deploy` (+ wrangler.jsonc sync).
|
|
171
|
+
*/
|
|
172
|
+
export async function connectDeploy(
|
|
173
|
+
config: ProjectConfig,
|
|
174
|
+
opts: ConnectOptions,
|
|
175
|
+
): Promise<DeployConfig> {
|
|
176
|
+
const existing = await getPagesProject(opts.accountId, opts.projectName);
|
|
177
|
+
const project = existing ?? (await createPagesProject(opts));
|
|
178
|
+
const deploy: DeployConfig = {
|
|
179
|
+
provider: "cloudflare-pages",
|
|
180
|
+
accountId: opts.accountId,
|
|
181
|
+
projectName: opts.projectName,
|
|
182
|
+
...(project.subdomain ? { productionUrl: `https://${project.subdomain}` } : {}),
|
|
183
|
+
};
|
|
184
|
+
await writeDeployConfig(config, deploy);
|
|
185
|
+
return deploy;
|
|
186
|
+
}
|