@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.
- package/dist/studio.css +3676 -0
- package/dist/studio.js +188743 -0
- package/dist/studio.js.map +1448 -0
- package/package.json +67 -0
- package/src/editor/context-menu.js +144 -0
- package/src/editor/inline-edit.js +597 -0
- package/src/editor/inline-format.js +572 -0
- package/src/editor/shortcuts.js +275 -0
- package/src/editor/slash-menu.js +167 -0
- package/src/files/components.js +40 -0
- package/src/files/file-ops.js +195 -0
- package/src/files/files.js +569 -0
- package/src/markdown/md-allowlist.js +101 -0
- package/src/markdown/md-convert.js +491 -0
- package/src/panels/activity-bar.js +69 -0
- package/src/panels/data-explorer.js +181 -0
- package/src/panels/events-panel.js +235 -0
- package/src/panels/imports-panel.js +427 -0
- package/src/panels/signals-panel.js +1093 -0
- package/src/panels/statusbar.js +56 -0
- package/src/platform.js +31 -0
- package/src/platforms/devserver.js +293 -0
- package/src/services/cem-export.js +130 -0
- package/src/services/code-services.js +98 -0
- package/src/site-context.js +122 -0
- package/src/state.js +744 -0
- package/src/store.js +332 -0
- package/src/studio.js +7692 -0
- package/src/ui/icons.js +83 -0
- package/src/ui/jx-styled-combobox.js +142 -0
- package/src/ui/spectrum.js +238 -0
- package/src/utils/studio-utils.js +185 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File tree management — project loading, file tree rendering, and file CRUD.
|
|
3
|
+
*
|
|
4
|
+
* Functions that mutate state accept a context object with callbacks, following the same pattern as
|
|
5
|
+
* file-ops.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
9
|
+
import { unified } from "unified";
|
|
10
|
+
import remarkStringify from "remark-stringify";
|
|
11
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
12
|
+
import { jxToMd } from "../markdown/md-convert.js";
|
|
13
|
+
import { createState, projectState, setProjectState } from "../store.js";
|
|
14
|
+
import { getPlatform } from "../platform.js";
|
|
15
|
+
import { statusMessage } from "../panels/statusbar.js";
|
|
16
|
+
import { loadComponentRegistry } from "./components.js";
|
|
17
|
+
|
|
18
|
+
// ─── File icon map ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const fileIconMap = /** @type {Record<string, any>} */ ({
|
|
21
|
+
"sp-icon-folder-open": html`<sp-icon-folder-open></sp-icon-folder-open>`,
|
|
22
|
+
"sp-icon-folder": html`<sp-icon-folder></sp-icon-folder>`,
|
|
23
|
+
"sp-icon-file-code": html`<sp-icon-file-code></sp-icon-file-code>`,
|
|
24
|
+
"sp-icon-file-txt": html`<sp-icon-file-txt></sp-icon-file-txt>`,
|
|
25
|
+
"sp-icon-image": html`<sp-icon-image></sp-icon-image>`,
|
|
26
|
+
"sp-icon-document": html`<sp-icon-document></sp-icon-document>`,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ─── File management ──────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
async function loadDirectory(/** @type {any} */ dirPath) {
|
|
32
|
+
if (!projectState) return;
|
|
33
|
+
try {
|
|
34
|
+
const platform = getPlatform();
|
|
35
|
+
const entries = await platform.listDirectory(dirPath);
|
|
36
|
+
projectState.dirs.set(dirPath, entries);
|
|
37
|
+
} catch {
|
|
38
|
+
projectState.dirs.set(dirPath, []);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Probe the dev server for a root project and populate projectState. */
|
|
43
|
+
export async function loadProject() {
|
|
44
|
+
try {
|
|
45
|
+
const platform = getPlatform();
|
|
46
|
+
const result = await platform.probeRootProject();
|
|
47
|
+
if (!result) return;
|
|
48
|
+
const { meta, info } = result;
|
|
49
|
+
|
|
50
|
+
setProjectState({
|
|
51
|
+
root: meta.root,
|
|
52
|
+
name: info.isSiteProject ? info.projectConfig?.name || meta.name : meta.name,
|
|
53
|
+
projectRoot: ".",
|
|
54
|
+
isSiteProject: info.isSiteProject,
|
|
55
|
+
projectConfig: info.isSiteProject ? info.projectConfig : null,
|
|
56
|
+
projectDirs: info.directories || [],
|
|
57
|
+
dirs: new Map(),
|
|
58
|
+
expanded: new Set(),
|
|
59
|
+
selectedPath: null,
|
|
60
|
+
searchQuery: "",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (info.isSiteProject) {
|
|
64
|
+
await loadDirectory(".");
|
|
65
|
+
await loadComponentRegistry();
|
|
66
|
+
}
|
|
67
|
+
// If not a site project (monorepo) — show welcome prompt, don't load tree
|
|
68
|
+
} catch {
|
|
69
|
+
// Not on dev server — project features disabled
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Open Project (PAL-based) ─────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Open a project via the platform adapter.
|
|
77
|
+
*
|
|
78
|
+
* @param {{
|
|
79
|
+
* S: any;
|
|
80
|
+
* commit: (s: any) => void;
|
|
81
|
+
* renderActivityBar: () => void;
|
|
82
|
+
* renderLeftPanel: () => void;
|
|
83
|
+
* }} ctx
|
|
84
|
+
*/
|
|
85
|
+
export async function openProject({ S, commit, renderActivityBar, renderLeftPanel }) {
|
|
86
|
+
try {
|
|
87
|
+
const platform = getPlatform();
|
|
88
|
+
const result = await platform.openProject();
|
|
89
|
+
if (!result) return; // User cancelled
|
|
90
|
+
|
|
91
|
+
const { config, handle } = result;
|
|
92
|
+
|
|
93
|
+
setProjectState({
|
|
94
|
+
...projectState,
|
|
95
|
+
projectRoot: handle.root,
|
|
96
|
+
isSiteProject: true,
|
|
97
|
+
projectConfig: config,
|
|
98
|
+
name: config.name || handle.name,
|
|
99
|
+
dirs: new Map(),
|
|
100
|
+
expanded: new Set(),
|
|
101
|
+
selectedPath: null,
|
|
102
|
+
searchQuery: "",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await loadDirectory(".");
|
|
106
|
+
await loadComponentRegistry();
|
|
107
|
+
|
|
108
|
+
// Auto-expand key directories
|
|
109
|
+
const entries = projectState.dirs.get(".") || [];
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
if (e.type === "directory" && ["pages", "components", "layouts"].includes(e.name)) {
|
|
112
|
+
projectState.expanded.add(e.path || e.name);
|
|
113
|
+
await loadDirectory(e.path || e.name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
commit({ ...S, ui: { ...S.ui, leftTab: "files" } });
|
|
118
|
+
renderActivityBar();
|
|
119
|
+
renderLeftPanel();
|
|
120
|
+
statusMessage(`Opened project: ${projectState.name}`);
|
|
121
|
+
} catch (/** @type {any} */ e) {
|
|
122
|
+
statusMessage(`Error: ${e.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── File tree templates ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function fileTypeIconTpl(/** @type {any} */ name, /** @type {any} */ type) {
|
|
129
|
+
let tag;
|
|
130
|
+
if (type === "directory") {
|
|
131
|
+
tag = projectState?.expanded?.has(name) ? "sp-icon-folder-open" : "sp-icon-folder";
|
|
132
|
+
} else {
|
|
133
|
+
const ext = name.split(".").pop()?.toLowerCase();
|
|
134
|
+
switch (ext) {
|
|
135
|
+
case "json":
|
|
136
|
+
tag = "sp-icon-file-code";
|
|
137
|
+
break;
|
|
138
|
+
case "md":
|
|
139
|
+
tag = "sp-icon-file-txt";
|
|
140
|
+
break;
|
|
141
|
+
case "js":
|
|
142
|
+
case "ts":
|
|
143
|
+
tag = "sp-icon-file-code";
|
|
144
|
+
break;
|
|
145
|
+
case "css":
|
|
146
|
+
tag = "sp-icon-file-code";
|
|
147
|
+
break;
|
|
148
|
+
case "png":
|
|
149
|
+
case "jpg":
|
|
150
|
+
case "jpeg":
|
|
151
|
+
case "svg":
|
|
152
|
+
case "webp":
|
|
153
|
+
case "gif":
|
|
154
|
+
tag = "sp-icon-image";
|
|
155
|
+
break;
|
|
156
|
+
default:
|
|
157
|
+
tag = "sp-icon-document";
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return fileIconMap[tag] || fileIconMap["sp-icon-document"];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Render the file tree template for the left panel.
|
|
166
|
+
*
|
|
167
|
+
* @param {{
|
|
168
|
+
* openProject: () => void;
|
|
169
|
+
* openFileFromTree: (path: string) => void;
|
|
170
|
+
* renderLeftPanel: () => void;
|
|
171
|
+
* }} ctx
|
|
172
|
+
*/
|
|
173
|
+
export function renderFilesTemplate({
|
|
174
|
+
openProject: openProjectFn,
|
|
175
|
+
openFileFromTree: openFileFn,
|
|
176
|
+
renderLeftPanel,
|
|
177
|
+
}) {
|
|
178
|
+
if (!projectState) {
|
|
179
|
+
return html`<div class="file-tree-empty">No project loaded</div>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No project selected in a monorepo — show welcome prompt
|
|
183
|
+
if (!projectState.isSiteProject && projectState.projectRoot === ".") {
|
|
184
|
+
return html`<div class="file-tree-empty">
|
|
185
|
+
<p style="margin:0 0 12px">Open a project folder to get started.</p>
|
|
186
|
+
<sp-button variant="accent" size="s" @click=${openProjectFn}>Open Project</sp-button>
|
|
187
|
+
</div>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return html`
|
|
191
|
+
${projectState.isSiteProject
|
|
192
|
+
? html`
|
|
193
|
+
<div class="project-header">
|
|
194
|
+
<span class="project-name"
|
|
195
|
+
>${projectState.projectConfig?.name || projectState.name}</span
|
|
196
|
+
>
|
|
197
|
+
</div>
|
|
198
|
+
`
|
|
199
|
+
: nothing}
|
|
200
|
+
<div class="files-toolbar">
|
|
201
|
+
<sp-action-group size="xs" compact quiet>
|
|
202
|
+
<sp-action-button
|
|
203
|
+
size="xs"
|
|
204
|
+
label="New File"
|
|
205
|
+
@click=${() => createNewFile(".", renderLeftPanel)}
|
|
206
|
+
>
|
|
207
|
+
<sp-icon-add slot="icon"></sp-icon-add>
|
|
208
|
+
</sp-action-button>
|
|
209
|
+
<sp-action-button
|
|
210
|
+
size="xs"
|
|
211
|
+
label="Refresh"
|
|
212
|
+
@click=${async () => {
|
|
213
|
+
projectState.dirs.clear();
|
|
214
|
+
await loadDirectory(".");
|
|
215
|
+
for (const dir of projectState.expanded) await loadDirectory(dir);
|
|
216
|
+
renderLeftPanel();
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<sp-icon-refresh slot="icon"></sp-icon-refresh>
|
|
220
|
+
</sp-action-button>
|
|
221
|
+
</sp-action-group>
|
|
222
|
+
<sp-search
|
|
223
|
+
size="s"
|
|
224
|
+
quiet
|
|
225
|
+
placeholder="Filter files…"
|
|
226
|
+
value=${projectState.searchQuery}
|
|
227
|
+
@input=${(/** @type {any} */ e) => {
|
|
228
|
+
projectState.searchQuery = e.target.value;
|
|
229
|
+
renderLeftPanel();
|
|
230
|
+
}}
|
|
231
|
+
@submit=${(/** @type {any} */ e) => e.preventDefault()}
|
|
232
|
+
></sp-search>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="file-tree" role="tree" aria-label="Project files">
|
|
235
|
+
${renderTreeLevelTemplate(".", 0, { openFileFn, renderLeftPanel })}
|
|
236
|
+
</div>
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @returns {any} */
|
|
241
|
+
function renderTreeLevelTemplate(
|
|
242
|
+
/** @type {any} */ dirPath,
|
|
243
|
+
/** @type {any} */ depth,
|
|
244
|
+
/** @type {{ openFileFn: (path: string) => void; renderLeftPanel: () => void }} */ ctx,
|
|
245
|
+
) {
|
|
246
|
+
const entries = projectState.dirs.get(dirPath);
|
|
247
|
+
if (!entries) {
|
|
248
|
+
loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
|
|
249
|
+
return html`<div
|
|
250
|
+
class="file-tree-item"
|
|
251
|
+
style="padding-left:${8 + depth * 16}px;color:var(--fg-dim);font-style:italic"
|
|
252
|
+
>
|
|
253
|
+
Loading…
|
|
254
|
+
</div>`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const sorted = [...entries].sort((a, b) => {
|
|
258
|
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
259
|
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
260
|
+
return a.name.localeCompare(b.name);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const query = projectState.searchQuery.toLowerCase();
|
|
264
|
+
const filtered = query
|
|
265
|
+
? sorted.filter((e) => e.type === "directory" || e.name.toLowerCase().includes(query))
|
|
266
|
+
: sorted;
|
|
267
|
+
|
|
268
|
+
return filtered.map((entry) => {
|
|
269
|
+
const isDir = entry.type === "directory";
|
|
270
|
+
const isExpanded = projectState.expanded.has(entry.path);
|
|
271
|
+
const isSelected = projectState.selectedPath === entry.path;
|
|
272
|
+
|
|
273
|
+
return html`
|
|
274
|
+
<div
|
|
275
|
+
class="file-tree-item${isSelected ? " selected" : ""}"
|
|
276
|
+
style="padding-left:${8 + depth * 16}px"
|
|
277
|
+
role="treeitem"
|
|
278
|
+
aria-level=${depth + 1}
|
|
279
|
+
tabindex="-1"
|
|
280
|
+
data-path=${entry.path}
|
|
281
|
+
data-type=${entry.type}
|
|
282
|
+
aria-expanded=${isDir ? String(isExpanded) : nothing}
|
|
283
|
+
@click=${async (/** @type {any} */ e) => {
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
if (isDir) {
|
|
286
|
+
if (isExpanded) projectState.expanded.delete(entry.path);
|
|
287
|
+
else {
|
|
288
|
+
projectState.expanded.add(entry.path);
|
|
289
|
+
if (!projectState.dirs.has(entry.path)) await loadDirectory(entry.path);
|
|
290
|
+
}
|
|
291
|
+
ctx.renderLeftPanel();
|
|
292
|
+
} else {
|
|
293
|
+
ctx.openFileFn(entry.path);
|
|
294
|
+
}
|
|
295
|
+
}}
|
|
296
|
+
@contextmenu=${(/** @type {any} */ e) => {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
e.stopPropagation();
|
|
299
|
+
showFileContextMenu(e, entry, ctx);
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
${isDir
|
|
303
|
+
? html`<span class="file-tree-toggle">${isExpanded ? "\u25bc" : "\u25b6"}</span>`
|
|
304
|
+
: html`<span class="file-tree-toggle empty"> </span>`}
|
|
305
|
+
<span class="file-tree-icon">${fileTypeIconTpl(entry.path, entry.type)}</span>
|
|
306
|
+
<span class="file-tree-name">${entry.name}</span>
|
|
307
|
+
</div>
|
|
308
|
+
${isDir && isExpanded
|
|
309
|
+
? html`<div role="group">${renderTreeLevelTemplate(entry.path, depth + 1, ctx)}</div>`
|
|
310
|
+
: nothing}
|
|
311
|
+
`;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function setupTreeKeyboard(/** @type {any} */ tree) {
|
|
316
|
+
tree.addEventListener("keydown", (/** @type {any} */ e) => {
|
|
317
|
+
const items = [...tree.querySelectorAll(".file-tree-item")];
|
|
318
|
+
const focused = tree.querySelector(".file-tree-item:focus");
|
|
319
|
+
if (!focused || items.length === 0) return;
|
|
320
|
+
|
|
321
|
+
const idx = items.indexOf(focused);
|
|
322
|
+
let handled = true;
|
|
323
|
+
|
|
324
|
+
switch (e.key) {
|
|
325
|
+
case "ArrowDown":
|
|
326
|
+
if (idx < items.length - 1) items[idx + 1].focus();
|
|
327
|
+
break;
|
|
328
|
+
case "ArrowUp":
|
|
329
|
+
if (idx > 0) items[idx - 1].focus();
|
|
330
|
+
break;
|
|
331
|
+
case "ArrowRight":
|
|
332
|
+
if (focused.dataset.type === "directory") {
|
|
333
|
+
const path = focused.dataset.path;
|
|
334
|
+
if (!projectState.expanded.has(path)) {
|
|
335
|
+
projectState.expanded.add(path);
|
|
336
|
+
loadDirectory(path).then(() => {
|
|
337
|
+
const panel = tree.closest(".panel-body");
|
|
338
|
+
if (panel) panel.querySelector(".file-tree-item:focus")?.click();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
case "ArrowLeft":
|
|
344
|
+
if (focused.dataset.type === "directory") {
|
|
345
|
+
const path = focused.dataset.path;
|
|
346
|
+
if (projectState.expanded.has(path)) {
|
|
347
|
+
projectState.expanded.delete(path);
|
|
348
|
+
// renderLeftPanel will be called by the caller who sets up keyboard
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
case "Enter":
|
|
353
|
+
focused.click();
|
|
354
|
+
break;
|
|
355
|
+
default:
|
|
356
|
+
handled = false;
|
|
357
|
+
}
|
|
358
|
+
if (handled) e.preventDefault();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Set first item focusable
|
|
362
|
+
const first = tree.querySelector(".file-tree-item");
|
|
363
|
+
if (first) first.setAttribute("tabindex", "0");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── Context menu ─────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/** @type {any} */
|
|
369
|
+
let fileContextPopover = null;
|
|
370
|
+
|
|
371
|
+
function showFileContextMenu(
|
|
372
|
+
/** @type {any} */ e,
|
|
373
|
+
/** @type {any} */ entry,
|
|
374
|
+
/** @type {{ openFileFn: (path: string) => void; renderLeftPanel: () => void }} */ ctx,
|
|
375
|
+
) {
|
|
376
|
+
if (fileContextPopover) {
|
|
377
|
+
fileContextPopover.remove();
|
|
378
|
+
fileContextPopover = null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const isDir = entry.type === "directory";
|
|
382
|
+
fileContextPopover = document.createElement("div");
|
|
383
|
+
|
|
384
|
+
const tpl = html`
|
|
385
|
+
<sp-popover
|
|
386
|
+
placement="right-start"
|
|
387
|
+
open
|
|
388
|
+
style="position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:9999"
|
|
389
|
+
>
|
|
390
|
+
<sp-menu style="min-width:160px">
|
|
391
|
+
${!isDir
|
|
392
|
+
? html`<sp-menu-item
|
|
393
|
+
@click=${() => {
|
|
394
|
+
closeFileContextMenu();
|
|
395
|
+
ctx.openFileFn(entry.path);
|
|
396
|
+
}}
|
|
397
|
+
>Open</sp-menu-item
|
|
398
|
+
>`
|
|
399
|
+
: nothing}
|
|
400
|
+
${isDir
|
|
401
|
+
? html`<sp-menu-item
|
|
402
|
+
@click=${() => {
|
|
403
|
+
closeFileContextMenu();
|
|
404
|
+
createNewFile(entry.path, ctx.renderLeftPanel);
|
|
405
|
+
}}
|
|
406
|
+
>New File…</sp-menu-item
|
|
407
|
+
>`
|
|
408
|
+
: nothing}
|
|
409
|
+
<sp-menu-divider></sp-menu-divider>
|
|
410
|
+
<sp-menu-item
|
|
411
|
+
@click=${() => {
|
|
412
|
+
closeFileContextMenu();
|
|
413
|
+
renameFile(entry, ctx.renderLeftPanel);
|
|
414
|
+
}}
|
|
415
|
+
>Rename…</sp-menu-item
|
|
416
|
+
>
|
|
417
|
+
<sp-menu-item
|
|
418
|
+
style="color:var(--danger)"
|
|
419
|
+
@click=${() => {
|
|
420
|
+
closeFileContextMenu();
|
|
421
|
+
deleteFile(entry, ctx.renderLeftPanel);
|
|
422
|
+
}}
|
|
423
|
+
>Delete</sp-menu-item
|
|
424
|
+
>
|
|
425
|
+
</sp-menu>
|
|
426
|
+
</sp-popover>
|
|
427
|
+
`;
|
|
428
|
+
|
|
429
|
+
litRender(tpl, fileContextPopover);
|
|
430
|
+
document.body.appendChild(fileContextPopover);
|
|
431
|
+
|
|
432
|
+
const closeHandler = (/** @type {any} */ ev) => {
|
|
433
|
+
if (!fileContextPopover?.contains(ev.target)) {
|
|
434
|
+
closeFileContextMenu();
|
|
435
|
+
document.removeEventListener("mousedown", closeHandler, true);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
setTimeout(() => document.addEventListener("mousedown", closeHandler, true), 0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function closeFileContextMenu() {
|
|
442
|
+
if (fileContextPopover) {
|
|
443
|
+
fileContextPopover.remove();
|
|
444
|
+
fileContextPopover = null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── File CRUD ────────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
async function createNewFile(dirPath = ".", /** @type {() => void} */ renderLeftPanel) {
|
|
451
|
+
const name = prompt("File name:", "untitled.json");
|
|
452
|
+
if (!name) return;
|
|
453
|
+
const path = dirPath === "." ? name : `${dirPath}/${name}`;
|
|
454
|
+
const content = name.endsWith(".md")
|
|
455
|
+
? "---\ntitle: Untitled\n---\n\n"
|
|
456
|
+
: JSON.stringify({ tagName: "div", children: [] }, null, 2);
|
|
457
|
+
try {
|
|
458
|
+
const platform = getPlatform();
|
|
459
|
+
await platform.writeFile(path, content);
|
|
460
|
+
await loadDirectory(dirPath);
|
|
461
|
+
renderLeftPanel();
|
|
462
|
+
statusMessage(`Created ${path}`);
|
|
463
|
+
} catch (/** @type {any} */ e) {
|
|
464
|
+
statusMessage(`Error: ${e.message}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function renameFile(/** @type {any} */ entry, /** @type {() => void} */ renderLeftPanel) {
|
|
469
|
+
const newName = prompt("New name:", entry.name);
|
|
470
|
+
if (!newName || newName === entry.name) return;
|
|
471
|
+
const parentDir = entry.path.includes("/")
|
|
472
|
+
? entry.path.substring(0, entry.path.lastIndexOf("/"))
|
|
473
|
+
: ".";
|
|
474
|
+
const newPath = parentDir === "." ? newName : `${parentDir}/${newName}`;
|
|
475
|
+
try {
|
|
476
|
+
const platform = getPlatform();
|
|
477
|
+
await platform.renameFile(entry.path, newPath);
|
|
478
|
+
await loadDirectory(parentDir);
|
|
479
|
+
if (projectState.selectedPath === entry.path) {
|
|
480
|
+
projectState.selectedPath = newPath;
|
|
481
|
+
}
|
|
482
|
+
renderLeftPanel();
|
|
483
|
+
statusMessage(`Renamed to ${newName}`);
|
|
484
|
+
} catch (/** @type {any} */ e) {
|
|
485
|
+
statusMessage(`Error: ${e.message}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function deleteFile(/** @type {any} */ entry, /** @type {() => void} */ renderLeftPanel) {
|
|
490
|
+
if (!confirm(`Delete "${entry.name}"?`)) return;
|
|
491
|
+
try {
|
|
492
|
+
const platform = getPlatform();
|
|
493
|
+
await platform.deleteFile(entry.path);
|
|
494
|
+
const parentDir = entry.path.includes("/")
|
|
495
|
+
? entry.path.substring(0, entry.path.lastIndexOf("/"))
|
|
496
|
+
: ".";
|
|
497
|
+
await loadDirectory(parentDir);
|
|
498
|
+
if (projectState.selectedPath === entry.path) {
|
|
499
|
+
projectState.selectedPath = null;
|
|
500
|
+
}
|
|
501
|
+
renderLeftPanel();
|
|
502
|
+
statusMessage(`Deleted ${entry.name}`);
|
|
503
|
+
} catch (/** @type {any} */ e) {
|
|
504
|
+
statusMessage(`Error: ${e.message}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ─── Open file from tree ──────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Open a file from the file tree — auto-saves current dirty doc, then loads the new one.
|
|
512
|
+
*
|
|
513
|
+
* @param {{
|
|
514
|
+
* S: any;
|
|
515
|
+
* commit: (s: any) => void;
|
|
516
|
+
* render: () => void;
|
|
517
|
+
* loadMarkdown: (source: string, handle: any) => void;
|
|
518
|
+
* }} ctx
|
|
519
|
+
* @param {string} path
|
|
520
|
+
*/
|
|
521
|
+
export async function openFileFromTree(ctx, path) {
|
|
522
|
+
const platform = getPlatform();
|
|
523
|
+
// Auto-save current dirty document
|
|
524
|
+
if (ctx.S.dirty && ctx.S.documentPath) {
|
|
525
|
+
try {
|
|
526
|
+
const isContent = ctx.S.mode === "content";
|
|
527
|
+
let output;
|
|
528
|
+
if (isContent) {
|
|
529
|
+
const mdast = jxToMd(ctx.S.document);
|
|
530
|
+
const md = unified()
|
|
531
|
+
.use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
|
|
532
|
+
.stringify(mdast);
|
|
533
|
+
const fm = ctx.S.content?.frontmatter;
|
|
534
|
+
const hasFrontmatter = fm && Object.keys(fm).length > 0;
|
|
535
|
+
output = hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
|
|
536
|
+
} else {
|
|
537
|
+
output = JSON.stringify(ctx.S.document, null, 2);
|
|
538
|
+
}
|
|
539
|
+
await platform.writeFile(ctx.S.documentPath, output);
|
|
540
|
+
} catch (/** @type {any} */ e) {
|
|
541
|
+
statusMessage(`Save error: ${e.message}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Fetch the file
|
|
546
|
+
try {
|
|
547
|
+
const content = await platform.readFile(path);
|
|
548
|
+
if (!content) return;
|
|
549
|
+
|
|
550
|
+
if (path.endsWith(".md")) {
|
|
551
|
+
ctx.loadMarkdown(content, null);
|
|
552
|
+
ctx.S.documentPath = path;
|
|
553
|
+
} else {
|
|
554
|
+
const doc = JSON.parse(content);
|
|
555
|
+
const newS = createState(doc);
|
|
556
|
+
newS.documentPath = path;
|
|
557
|
+
newS.dirty = false;
|
|
558
|
+
ctx.commit(newS);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Update tree selection
|
|
562
|
+
projectState.selectedPath = path;
|
|
563
|
+
|
|
564
|
+
ctx.render();
|
|
565
|
+
statusMessage(`Opened ${path}`);
|
|
566
|
+
} catch (/** @type {any} */ e) {
|
|
567
|
+
statusMessage(`Error: ${e.message}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Md-allowlist.js — Markdown element allowlist and nesting constraints
|
|
3
|
+
*
|
|
4
|
+
* Defines which HTML elements are "native markdown" — they round-trip to pure markdown syntax.
|
|
5
|
+
* Everything else is a Jx component directive.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Block-level elements that map directly to markdown syntax */
|
|
9
|
+
export const MD_BLOCK = new Set([
|
|
10
|
+
"h1",
|
|
11
|
+
"h2",
|
|
12
|
+
"h3",
|
|
13
|
+
"h4",
|
|
14
|
+
"h5",
|
|
15
|
+
"h6",
|
|
16
|
+
"p",
|
|
17
|
+
"blockquote",
|
|
18
|
+
"ul",
|
|
19
|
+
"ol",
|
|
20
|
+
"li",
|
|
21
|
+
"pre",
|
|
22
|
+
"hr",
|
|
23
|
+
"table",
|
|
24
|
+
"thead",
|
|
25
|
+
"tbody",
|
|
26
|
+
"tr",
|
|
27
|
+
"th",
|
|
28
|
+
"td",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** Inline elements that map directly to markdown syntax */
|
|
32
|
+
export const MD_INLINE = new Set(["em", "strong", "del", "code", "a", "img", "br"]);
|
|
33
|
+
|
|
34
|
+
/** All markdown-native elements */
|
|
35
|
+
export const MD_ALL = new Set([...MD_BLOCK, ...MD_INLINE]);
|
|
36
|
+
|
|
37
|
+
/** Elements that cannot contain children */
|
|
38
|
+
export const MD_VOID = new Set(["hr", "br", "img"]);
|
|
39
|
+
|
|
40
|
+
/** Elements that contain only text, not child elements */
|
|
41
|
+
export const MD_TEXT_ONLY = new Set(["code"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Nesting constraints: which child elements are allowed inside each parent. null = any block/inline
|
|
45
|
+
* allowed (used for content root and directive components).
|
|
46
|
+
*
|
|
47
|
+
* @type {Record<
|
|
48
|
+
* string,
|
|
49
|
+
* { block: boolean; inline: boolean; directive: boolean; only: Set<string> | null }
|
|
50
|
+
* >}
|
|
51
|
+
*/
|
|
52
|
+
export const MD_NESTING = {
|
|
53
|
+
_root: { block: true, inline: false, directive: true, only: null },
|
|
54
|
+
h1: { block: false, inline: true, directive: false, only: null },
|
|
55
|
+
h2: { block: false, inline: true, directive: false, only: null },
|
|
56
|
+
h3: { block: false, inline: true, directive: false, only: null },
|
|
57
|
+
h4: { block: false, inline: true, directive: false, only: null },
|
|
58
|
+
h5: { block: false, inline: true, directive: false, only: null },
|
|
59
|
+
h6: { block: false, inline: true, directive: false, only: null },
|
|
60
|
+
p: { block: false, inline: true, directive: true, only: null },
|
|
61
|
+
blockquote: { block: true, inline: false, directive: true, only: null },
|
|
62
|
+
ul: { block: false, inline: false, directive: false, only: new Set(["li"]) },
|
|
63
|
+
ol: { block: false, inline: false, directive: false, only: new Set(["li"]) },
|
|
64
|
+
li: { block: true, inline: true, directive: true, only: null },
|
|
65
|
+
pre: { block: false, inline: false, directive: false, only: new Set(["code"]) },
|
|
66
|
+
table: { block: false, inline: false, directive: false, only: new Set(["thead", "tbody"]) },
|
|
67
|
+
thead: { block: false, inline: false, directive: false, only: new Set(["tr"]) },
|
|
68
|
+
tbody: { block: false, inline: false, directive: false, only: new Set(["tr"]) },
|
|
69
|
+
tr: { block: false, inline: false, directive: false, only: new Set(["th", "td"]) },
|
|
70
|
+
th: { block: false, inline: true, directive: false, only: null },
|
|
71
|
+
td: { block: false, inline: true, directive: false, only: null },
|
|
72
|
+
em: { block: false, inline: true, directive: false, only: null },
|
|
73
|
+
strong: { block: false, inline: true, directive: false, only: null },
|
|
74
|
+
del: { block: false, inline: true, directive: false, only: null },
|
|
75
|
+
a: { block: false, inline: true, directive: false, only: null },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check whether a tag is allowed as a child of the given parent tag in content mode.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} parentTag - Parent element tag (or '_root' for content root)
|
|
82
|
+
* @param {string} childTag - Proposed child element tag
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function isValidChild(parentTag, childTag) {
|
|
86
|
+
const rule = MD_NESTING[parentTag];
|
|
87
|
+
if (!rule) return true; // directive components allow anything
|
|
88
|
+
|
|
89
|
+
// If there's a strict allowlist, check it
|
|
90
|
+
if (rule.only) return rule.only.has(childTag);
|
|
91
|
+
|
|
92
|
+
const isBlock = MD_BLOCK.has(childTag);
|
|
93
|
+
const isInline = MD_INLINE.has(childTag);
|
|
94
|
+
const isDirective = !MD_ALL.has(childTag);
|
|
95
|
+
|
|
96
|
+
if (isBlock && rule.block) return true;
|
|
97
|
+
if (isInline && rule.inline) return true;
|
|
98
|
+
if (isDirective && rule.directive) return true;
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|