@jxsuite/studio 0.0.1

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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Imports panel — context-aware import manager with cherry-pick component selection.
3
+ *
4
+ * When editing project.json: shows Class Imports, Dependencies (add/remove packages), and
5
+ * per-package component toggles for cherry-picking individual elements. When editing a
6
+ * page/layout/component/collection: shows Component Imports ($ref picker) and per-package component
7
+ * toggles.
8
+ */
9
+
10
+ import { html, nothing } from "lit-html";
11
+ import { componentRegistry, computeRelativePath } from "../files/components.js";
12
+ import { projectState } from "../store.js";
13
+ import { updateSiteConfig } from "../site-context.js";
14
+ import { getPlatform } from "../platform.js";
15
+
16
+ /**
17
+ * Build the subpath specifier for a component: `<package>/<modulePath>`
18
+ *
19
+ * @param {any} comp
20
+ * @returns {string}
21
+ */
22
+ function componentSpecifier(comp) {
23
+ return `${comp.package}/${comp.modulePath}`;
24
+ }
25
+
26
+ /**
27
+ * Check if a component is enabled (present in $elements array). Supports both cherry-picked subpath
28
+ * specifiers and legacy full-package imports.
29
+ *
30
+ * @param {any} comp
31
+ * @param {any[]} elements
32
+ * @returns {boolean}
33
+ */
34
+ function isComponentEnabled(comp, elements) {
35
+ if (!elements?.length) return false;
36
+ const specifier = componentSpecifier(comp);
37
+ for (const entry of elements) {
38
+ if (typeof entry !== "string") continue;
39
+ // Cherry-picked subpath match
40
+ if (entry === specifier) return true;
41
+ // Legacy full-package match
42
+ if (entry === comp.package) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Group npm components by package name.
49
+ *
50
+ * @returns {Map<string, any[]>}
51
+ */
52
+ function groupByPackage() {
53
+ /** @type {Map<string, any[]>} */
54
+ const groups = new Map();
55
+ for (const comp of componentRegistry) {
56
+ if (comp.source !== "npm" || !comp.package || !comp.modulePath) continue;
57
+ if (!groups.has(comp.package)) groups.set(comp.package, []);
58
+ groups.get(comp.package)?.push(comp);
59
+ }
60
+ return groups;
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * renderLeftPanel: () => void;
66
+ * documentPath: string | null;
67
+ * documentElements: any[];
68
+ * applyMutation: (fn: (doc: any) => void) => void;
69
+ * }} ctx
70
+ * @returns {any}
71
+ */
72
+ export function renderImportsTemplate({
73
+ renderLeftPanel,
74
+ documentPath,
75
+ documentElements,
76
+ applyMutation,
77
+ }) {
78
+ const isSiteLevel = documentPath?.endsWith("project.json");
79
+
80
+ if (isSiteLevel) {
81
+ return renderSiteLevelImports(renderLeftPanel);
82
+ }
83
+
84
+ return renderDocumentLevelImports({
85
+ renderLeftPanel,
86
+ documentPath,
87
+ documentElements,
88
+ applyMutation,
89
+ });
90
+ }
91
+
92
+ // ─── Site-level: Class Imports + Dependencies + Component Cherry-pick ─────────
93
+
94
+ /** @param {() => void} renderLeftPanel */
95
+ function renderSiteLevelImports(renderLeftPanel) {
96
+ const siteImports = projectState?.projectConfig?.imports || {};
97
+ const entries = Object.entries(siteImports);
98
+ const siteElements = projectState?.projectConfig?.$elements || [];
99
+
100
+ const packageGroups = groupByPackage();
101
+
102
+ return html`
103
+ <div class="imports-panel">
104
+ <!-- Class Imports -->
105
+ <div class="imports-section">
106
+ <div class="imports-section-header">
107
+ <span class="imports-section-title">Class Imports</span>
108
+ <span class="imports-count">${entries.length}</span>
109
+ </div>
110
+ ${entries.length > 0
111
+ ? html`
112
+ <div class="imports-list">
113
+ ${entries.map(
114
+ ([name, path]) => html`
115
+ <div class="import-row">
116
+ <span class="import-name" title=${/** @type {string} */ (path)}>${name}</span>
117
+ <span class="import-path">${path}</span>
118
+ <sp-action-button
119
+ quiet
120
+ size="xs"
121
+ title="Remove"
122
+ @click=${async () => {
123
+ const updated = { ...siteImports };
124
+ delete updated[name];
125
+ await updateSiteConfig({ imports: updated });
126
+ renderLeftPanel();
127
+ }}
128
+ >
129
+ <sp-icon-close slot="icon" size="xs"></sp-icon-close>
130
+ </sp-action-button>
131
+ </div>
132
+ `,
133
+ )}
134
+ </div>
135
+ `
136
+ : html`<div class="imports-empty">No class imports</div>`}
137
+ <div class="import-add-form">
138
+ <sp-textfield placeholder="Name" size="s" class="import-add-name"></sp-textfield>
139
+ <sp-textfield placeholder="Path" size="s" class="import-add-path"></sp-textfield>
140
+ <sp-action-button
141
+ quiet
142
+ size="xs"
143
+ title="Add import"
144
+ @click=${async (/** @type {any} */ e) => {
145
+ const form = e.target.closest(".import-add-form");
146
+ const nameField = form?.querySelector(".import-add-name");
147
+ const pathField = form?.querySelector(".import-add-path");
148
+ const name = nameField?.value?.trim();
149
+ const path = pathField?.value?.trim();
150
+ if (!name || !path) return;
151
+ nameField.value = "";
152
+ pathField.value = "";
153
+ const updated = { ...siteImports, [name]: path };
154
+ await updateSiteConfig({ imports: updated });
155
+ renderLeftPanel();
156
+ }}
157
+ >
158
+ <sp-icon-add slot="icon" size="xs"></sp-icon-add>
159
+ </sp-action-button>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- npm Dependencies with per-component toggles -->
164
+ ${[...packageGroups.entries()].map(
165
+ ([pkg, comps]) => html`
166
+ <div class="imports-section">
167
+ <div class="imports-section-header">
168
+ <span class="imports-section-title import-mono">${pkg}</span>
169
+ <sp-action-button
170
+ quiet
171
+ size="xs"
172
+ title="Remove package"
173
+ @click=${async () => {
174
+ if (!confirm("Remove " + pkg + "?")) return;
175
+ try {
176
+ const platform = getPlatform();
177
+ await platform.removePackage(pkg);
178
+ // Also remove all cherry-picked elements for this package
179
+ const updatedElements = siteElements.filter(
180
+ (/** @type {any} */ e) => typeof e !== "string" || !e.startsWith(pkg + "/"),
181
+ );
182
+ const { loadComponentRegistry } = await import("../files/components.js");
183
+ await loadComponentRegistry();
184
+ await updateSiteConfig({ $elements: updatedElements });
185
+ renderLeftPanel();
186
+ } catch (/** @type {any} */ e) {
187
+ console.error("Failed to remove package:", e);
188
+ }
189
+ }}
190
+ >
191
+ <sp-icon-close slot="icon" size="xs"></sp-icon-close>
192
+ </sp-action-button>
193
+ </div>
194
+ <div class="imports-list imports-component-list">
195
+ ${comps.map((/** @type {any} */ comp) => {
196
+ const enabled = isComponentEnabled(comp, siteElements);
197
+ const specifier = componentSpecifier(comp);
198
+ return html`
199
+ <div class="import-row import-component-row">
200
+ <sp-checkbox
201
+ size="s"
202
+ .checked=${enabled}
203
+ @change=${async (/** @type {any} */ e) => {
204
+ let updated = [...siteElements];
205
+ // Remove legacy full-package import if present
206
+ updated = updated.filter((/** @type {any} */ el) => el !== pkg);
207
+ if (e.target.checked) {
208
+ if (!updated.includes(specifier)) updated.push(specifier);
209
+ } else {
210
+ updated = updated.filter((/** @type {any} */ el) => el !== specifier);
211
+ }
212
+ await updateSiteConfig({ $elements: updated });
213
+ renderLeftPanel();
214
+ }}
215
+ >
216
+ <span class="import-component-label">&lt;${comp.tagName}&gt;</span>
217
+ </sp-checkbox>
218
+ </div>
219
+ `;
220
+ })}
221
+ </div>
222
+ </div>
223
+ `,
224
+ )}
225
+
226
+ <!-- Add package -->
227
+ <div class="imports-section">
228
+ <div class="imports-section-header">
229
+ <span class="imports-section-title">Add Dependency</span>
230
+ </div>
231
+ <div class="import-add-form">
232
+ <sp-textfield
233
+ placeholder="Package name…"
234
+ size="s"
235
+ style="flex:1"
236
+ @keydown=${async (/** @type {any} */ e) => {
237
+ if (e.key !== "Enter") return;
238
+ const name = e.target.value?.trim();
239
+ if (!name) return;
240
+ e.target.value = "";
241
+ try {
242
+ const platform = getPlatform();
243
+ await platform.addPackage(name);
244
+ const { loadComponentRegistry } = await import("../files/components.js");
245
+ await loadComponentRegistry();
246
+ renderLeftPanel();
247
+ } catch (/** @type {any} */ err) {
248
+ console.error("Failed to add package:", err);
249
+ }
250
+ }}
251
+ ></sp-textfield>
252
+ <sp-action-button
253
+ quiet
254
+ size="xs"
255
+ title="Add package"
256
+ @click=${async (/** @type {any} */ e) => {
257
+ const input = e.target.closest(".import-add-form")?.querySelector("sp-textfield");
258
+ const name = input?.value?.trim();
259
+ if (!name) return;
260
+ input.value = "";
261
+ try {
262
+ const platform = getPlatform();
263
+ await platform.addPackage(name);
264
+ const { loadComponentRegistry } = await import("../files/components.js");
265
+ await loadComponentRegistry();
266
+ renderLeftPanel();
267
+ } catch (/** @type {any} */ err) {
268
+ console.error("Failed to add package:", err);
269
+ }
270
+ }}
271
+ >
272
+ <sp-icon-add slot="icon" size="xs"></sp-icon-add>
273
+ </sp-action-button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ `;
278
+ }
279
+
280
+ // ─── Document-level: Component Imports + npm Component Cherry-pick ───────────
281
+
282
+ /**
283
+ * @param {{
284
+ * renderLeftPanel: () => void;
285
+ * documentPath: string | null;
286
+ * documentElements: any[];
287
+ * applyMutation: (fn: (doc: any) => void) => void;
288
+ * }} ctx
289
+ */
290
+ function renderDocumentLevelImports({
291
+ renderLeftPanel,
292
+ documentPath,
293
+ documentElements,
294
+ applyMutation,
295
+ }) {
296
+ const refEntries = documentElements.filter(
297
+ (/** @type {any} */ e) => e && typeof e === "object" && e.$ref,
298
+ );
299
+ const npmEntries = documentElements.filter((/** @type {any} */ e) => typeof e === "string");
300
+
301
+ // Available JX components not yet imported
302
+ const importedRefs = new Set(refEntries.map((/** @type {any} */ e) => e.$ref));
303
+ const availableComponents = componentRegistry.filter(
304
+ (/** @type {any} */ c) =>
305
+ c.source !== "npm" && !importedRefs.has(`./${c.path}`) && !importedRefs.has(c.path),
306
+ );
307
+
308
+ const packageGroups = groupByPackage();
309
+
310
+ /** @param {string} ref */
311
+ const removeRef = (ref) => {
312
+ applyMutation((/** @type {any} */ doc) => {
313
+ doc.$elements = (doc.$elements || []).filter(
314
+ (/** @type {any} */ e) => !(e && typeof e === "object" && e.$ref === ref),
315
+ );
316
+ });
317
+ renderLeftPanel();
318
+ };
319
+
320
+ return html`
321
+ <div class="imports-panel">
322
+ <!-- Component Imports ($ref) -->
323
+ <div class="imports-section">
324
+ <div class="imports-section-header">
325
+ <span class="imports-section-title">Components</span>
326
+ <span class="imports-count">${refEntries.length}</span>
327
+ </div>
328
+ ${refEntries.length > 0
329
+ ? html`
330
+ <div class="imports-list">
331
+ ${refEntries.map(
332
+ (/** @type {any} */ entry) => html`
333
+ <div class="import-row">
334
+ <span class="import-path" title=${entry.$ref}>${entry.$ref}</span>
335
+ <sp-action-button
336
+ quiet
337
+ size="xs"
338
+ title="Remove"
339
+ @click=${() => removeRef(entry.$ref)}
340
+ >
341
+ <sp-icon-close slot="icon" size="xs"></sp-icon-close>
342
+ </sp-action-button>
343
+ </div>
344
+ `,
345
+ )}
346
+ </div>
347
+ `
348
+ : nothing}
349
+ ${availableComponents.length > 0
350
+ ? html`
351
+ <div class="import-add-form">
352
+ <sp-picker
353
+ size="s"
354
+ label="Add component…"
355
+ class="import-picker"
356
+ @change=${(/** @type {any} */ e) => {
357
+ const tag = e.target.value;
358
+ if (!tag) return;
359
+ e.target.value = "";
360
+ const comp = componentRegistry.find(
361
+ (/** @type {any} */ c) => c.tagName === tag,
362
+ );
363
+ if (!comp) return;
364
+ const relPath = computeRelativePath(documentPath, comp.path);
365
+ applyMutation((/** @type {any} */ doc) => {
366
+ if (!doc.$elements) doc.$elements = [];
367
+ doc.$elements.push({ $ref: relPath });
368
+ });
369
+ renderLeftPanel();
370
+ }}
371
+ >
372
+ ${availableComponents.map(
373
+ (/** @type {any} */ c) =>
374
+ html`<sp-menu-item value=${c.tagName}>&lt;${c.tagName}&gt;</sp-menu-item>`,
375
+ )}
376
+ </sp-picker>
377
+ </div>
378
+ `
379
+ : nothing}
380
+ </div>
381
+
382
+ <!-- npm Package Components (cherry-pick toggles) -->
383
+ ${[...packageGroups.entries()].map(
384
+ ([pkg, comps]) => html`
385
+ <div class="imports-section">
386
+ <div class="imports-section-header">
387
+ <span class="imports-section-title import-mono">${pkg}</span>
388
+ </div>
389
+ <div class="imports-list imports-component-list">
390
+ ${comps.map((/** @type {any} */ comp) => {
391
+ const enabled = isComponentEnabled(comp, npmEntries);
392
+ const specifier = componentSpecifier(comp);
393
+ return html`
394
+ <div class="import-row import-component-row">
395
+ <sp-checkbox
396
+ size="s"
397
+ .checked=${enabled}
398
+ @change=${(/** @type {any} */ e) => {
399
+ applyMutation((/** @type {any} */ doc) => {
400
+ if (!doc.$elements) doc.$elements = [];
401
+ // Remove legacy full-package import if present
402
+ doc.$elements = doc.$elements.filter(
403
+ (/** @type {any} */ el) => el !== pkg,
404
+ );
405
+ if (e.target.checked) {
406
+ if (!doc.$elements.includes(specifier)) doc.$elements.push(specifier);
407
+ } else {
408
+ doc.$elements = doc.$elements.filter(
409
+ (/** @type {any} */ el) => el !== specifier,
410
+ );
411
+ }
412
+ });
413
+ renderLeftPanel();
414
+ }}
415
+ >
416
+ <span class="import-component-label">&lt;${comp.tagName}&gt;</span>
417
+ </sp-checkbox>
418
+ </div>
419
+ `;
420
+ })}
421
+ </div>
422
+ </div>
423
+ `,
424
+ )}
425
+ </div>
426
+ `;
427
+ }