@jxsuite/studio 0.29.0 → 0.30.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 +98 -98
- package/dist/studio.js +4174 -2762
- package/dist/studio.js.map +35 -16
- package/dist/workers/editor.worker.js +79 -79
- package/dist/workers/json.worker.js +109 -109
- package/dist/workers/ts.worker.js +82 -82
- package/package.json +10 -10
- package/src/editor/context-menu.ts +4 -2
- package/src/files/files.ts +43 -3
- package/src/files/fs-events.ts +162 -0
- package/src/panels/dnd.ts +2 -2
- package/src/panels/signals-panel.ts +19 -11
- package/src/platforms/devserver.ts +54 -2
- package/src/studio.ts +15 -2
- package/src/types.ts +30 -1
- package/src/ui/panel-resize.ts +8 -7
- package/src/ui/unit-selector.ts +3 -0
- package/src/utils/canvas-media.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "Jx Studio — visual builder for Jx documents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"provenance": true
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "bun build ./src/studio.ts --outdir dist --target browser --sourcemap=linked && bun
|
|
26
|
+
"build": "bun build ./src/studio.ts --outdir dist --target browser --sourcemap=linked && bun run scripts/build-workers.ts",
|
|
27
27
|
"build:metafile": "bun build ./src/studio.ts --outdir dist --target browser --sourcemap=linked --metafile=metafile.json",
|
|
28
28
|
"gen:webdata": "bun run scripts/gen-webdata.js",
|
|
29
29
|
"test": "bun test --isolate",
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@atlaskit/pragmatic-drag-and-drop": "^
|
|
35
|
-
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^
|
|
36
|
-
"@jxsuite/parser": "^0.
|
|
37
|
-
"@jxsuite/runtime": "^0.
|
|
38
|
-
"@jxsuite/schema": "^0.
|
|
34
|
+
"@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
|
|
35
|
+
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
|
|
36
|
+
"@jxsuite/parser": "^0.30.1",
|
|
37
|
+
"@jxsuite/runtime": "^0.30.1",
|
|
38
|
+
"@jxsuite/schema": "^0.30.1",
|
|
39
39
|
"@spectrum-web-components/accordion": "^1.12.1",
|
|
40
40
|
"@spectrum-web-components/action-bar": "1.12.1",
|
|
41
41
|
"@spectrum-web-components/action-button": "^1.12.1",
|
|
@@ -79,11 +79,11 @@
|
|
|
79
79
|
"yaml": "^2.9.0"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@happy-dom/global-registrator": "^20.10.
|
|
82
|
+
"@happy-dom/global-registrator": "^20.10.6",
|
|
83
83
|
"@types/mdast": "^4.0.4",
|
|
84
|
-
"@webref/css": "^8.
|
|
84
|
+
"@webref/css": "^8.6.0",
|
|
85
85
|
"@webref/elements": "^2.7.1",
|
|
86
|
-
"@webref/idl": "^3.81.
|
|
86
|
+
"@webref/idl": "^3.81.1"
|
|
87
87
|
},
|
|
88
88
|
"//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
|
|
89
89
|
}
|
|
@@ -70,10 +70,12 @@ function nodeToHtml(node: JxNode | string): string {
|
|
|
70
70
|
async function writeToClipboard(json: Record<string, unknown>) {
|
|
71
71
|
workspace.clipboard = json;
|
|
72
72
|
try {
|
|
73
|
+
const jxBlob = new Blob([JSON.stringify(json)], { type: JX_MIME });
|
|
74
|
+
const htmlBlob = new Blob([nodeToHtml(json)], { type: "text/html" });
|
|
73
75
|
await navigator.clipboard.write([
|
|
74
76
|
new ClipboardItem({
|
|
75
|
-
[JX_MIME]:
|
|
76
|
-
"text/html":
|
|
77
|
+
[JX_MIME]: jxBlob,
|
|
78
|
+
"text/html": htmlBlob,
|
|
77
79
|
}),
|
|
78
80
|
]);
|
|
79
81
|
} catch {
|
package/src/files/files.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { createState, projectState, requireProjectState, setProjectState } from
|
|
|
16
16
|
import { getPlatform } from "../platform";
|
|
17
17
|
import { statusMessage } from "../panels/statusbar";
|
|
18
18
|
import { loadComponentRegistry } from "./components";
|
|
19
|
+
import { markLocalMutation } from "./fs-events";
|
|
19
20
|
import {
|
|
20
21
|
draggable,
|
|
21
22
|
dropTargetForElements,
|
|
@@ -44,6 +45,7 @@ import type { TemplateResult } from "lit-html";
|
|
|
44
45
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
45
46
|
import type { StudioState } from "../state.js";
|
|
46
47
|
import type { Tab } from "../tabs/tab.js";
|
|
48
|
+
import type { RenameResult } from "../types";
|
|
47
49
|
|
|
48
50
|
// ─── File icon map ────────────────────────────────────────────────────────────
|
|
49
51
|
|
|
@@ -636,8 +638,9 @@ export function registerFileTreeDnD({ renderLeftPanel }: { renderLeftPanel: () =
|
|
|
636
638
|
*/
|
|
637
639
|
async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel: () => void) {
|
|
638
640
|
const platform = getPlatform();
|
|
641
|
+
markLocalMutation(oldPath, newPath);
|
|
639
642
|
try {
|
|
640
|
-
await platform.renameFile(oldPath, newPath);
|
|
643
|
+
const report = await platform.renameFile(oldPath, newPath);
|
|
641
644
|
|
|
642
645
|
// Update open tabs referencing the moved path
|
|
643
646
|
for (const [id] of workspace.tabs.entries()) {
|
|
@@ -662,6 +665,7 @@ async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel:
|
|
|
662
665
|
requireProjectState().expanded.add(newParent);
|
|
663
666
|
}
|
|
664
667
|
|
|
668
|
+
reloadRewrittenTabs(report, newPath);
|
|
665
669
|
renderLeftPanel();
|
|
666
670
|
statusMessage(`Moved to ${newPath}`);
|
|
667
671
|
} catch (error) {
|
|
@@ -779,6 +783,7 @@ async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
|
|
|
779
783
|
return;
|
|
780
784
|
}
|
|
781
785
|
const path = dirPath === "." ? name : `${dirPath}/${name}`;
|
|
786
|
+
markLocalMutation(path);
|
|
782
787
|
await loadFormats();
|
|
783
788
|
const format = formatForPath(name);
|
|
784
789
|
const content =
|
|
@@ -849,6 +854,28 @@ function showRenameFileDialog(currentName: string): Promise<string | null> {
|
|
|
849
854
|
});
|
|
850
855
|
}
|
|
851
856
|
|
|
857
|
+
/** Build the status-bar message for a rename, summarising any reference/tag rewrites. */
|
|
858
|
+
function renameStatus(newName: string, report: RenameResult): string {
|
|
859
|
+
const refs = report.references;
|
|
860
|
+
const tagNote = report.tag ? `; tag → <${report.tag.to}> (${report.tag.refsUpdated})` : "";
|
|
861
|
+
if (refs && refs.refsUpdated > 0) {
|
|
862
|
+
return `Renamed to ${newName}; updated ${refs.refsUpdated} reference(s) in ${refs.filesChanged} file(s)${tagNote}`;
|
|
863
|
+
}
|
|
864
|
+
if (tagNote) {
|
|
865
|
+
return `Renamed to ${newName}${tagNote}`;
|
|
866
|
+
}
|
|
867
|
+
return `Renamed to ${newName}`;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Reload any open tabs whose references the refactor rewrote (so the editor shows new paths). */
|
|
871
|
+
function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
|
|
872
|
+
for (const f of report.references?.files ?? []) {
|
|
873
|
+
if (f.path !== skipPath && workspace.tabs.has(f.path)) {
|
|
874
|
+
void reloadFileInTab(f.path);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
852
879
|
async function renameFile(
|
|
853
880
|
entry: { name: string; path: string; type: string },
|
|
854
881
|
renderLeftPanel: () => void,
|
|
@@ -862,9 +889,10 @@ async function renameFile(
|
|
|
862
889
|
? entryPath.slice(0, entryPath.lastIndexOf("/"))
|
|
863
890
|
: ".";
|
|
864
891
|
const newPath = parentDirPath === "." ? newName : `${parentDirPath}/${newName}`;
|
|
892
|
+
markLocalMutation(entry.path, newPath);
|
|
865
893
|
try {
|
|
866
894
|
const platform = getPlatform();
|
|
867
|
-
await platform.renameFile(entry.path, newPath);
|
|
895
|
+
const report = await platform.renameFile(entry.path, newPath);
|
|
868
896
|
await loadDirectory(parentDirPath);
|
|
869
897
|
if (requireProjectState().selectedPath === entry.path) {
|
|
870
898
|
requireProjectState().selectedPath = newPath;
|
|
@@ -872,8 +900,9 @@ async function renameFile(
|
|
|
872
900
|
if (workspace.tabs.has(entry.path)) {
|
|
873
901
|
renameTab(entry.path, newPath, newPath);
|
|
874
902
|
}
|
|
903
|
+
reloadRewrittenTabs(report, newPath);
|
|
875
904
|
renderLeftPanel();
|
|
876
|
-
statusMessage(
|
|
905
|
+
statusMessage(renameStatus(newName, report));
|
|
877
906
|
} catch (error) {
|
|
878
907
|
statusMessage(`Error: ${errorMessage(error)}`);
|
|
879
908
|
}
|
|
@@ -892,6 +921,7 @@ async function deleteFile(
|
|
|
892
921
|
}
|
|
893
922
|
try {
|
|
894
923
|
const platform = getPlatform();
|
|
924
|
+
markLocalMutation(entry.path);
|
|
895
925
|
await platform.deleteFile(entry.path);
|
|
896
926
|
const delPath = entry.path.replaceAll("\\", "/");
|
|
897
927
|
const parentDirPath = delPath.includes("/") ? delPath.slice(0, delPath.lastIndexOf("/")) : ".";
|
|
@@ -1045,6 +1075,16 @@ export async function openFileInTab(path: string) {
|
|
|
1045
1075
|
*
|
|
1046
1076
|
* @param {string} path
|
|
1047
1077
|
*/
|
|
1078
|
+
/** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
|
|
1079
|
+
export function reloadCleanTab(path: string): void {
|
|
1080
|
+
for (const [, tab] of workspace.tabs.entries()) {
|
|
1081
|
+
if (tab.documentPath === path && !tab.doc.dirty) {
|
|
1082
|
+
void reloadFileInTab(path);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1048
1088
|
export async function reloadFileInTab(path: string) {
|
|
1049
1089
|
for (const [, tab] of workspace.tabs.entries()) {
|
|
1050
1090
|
if (tab.documentPath === path) {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Fs-events.ts — reconcile backend filesystem events into the cached sidebar tree.
|
|
4
|
+
*
|
|
5
|
+
* `applyFsEvents` is a pure, in-place reducer over `projectState.dirs`/`expanded` (unit-testable
|
|
6
|
+
* without a DOM). `startFsSync` is the only impure part: it feature-detects the platform's
|
|
7
|
+
* `subscribeFileEvents`, debounces bursts, drops the echoes of the user's own local mutations, and
|
|
8
|
+
* triggers a single re-render through the existing left-panel render path (no imperative DOM).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getPlatform } from "../platform";
|
|
12
|
+
import { projectState } from "../store";
|
|
13
|
+
import type { DirEntry, FsEvent } from "../types";
|
|
14
|
+
|
|
15
|
+
const RECENT_MS = 1500;
|
|
16
|
+
const recentLocal = new Map<string, number>();
|
|
17
|
+
|
|
18
|
+
const norm = (p: string) => p.replaceAll("\\", "/");
|
|
19
|
+
|
|
20
|
+
/** Mark paths the user just mutated locally so the watcher's echo of them is ignored briefly. */
|
|
21
|
+
export function markLocalMutation(...paths: string[]): void {
|
|
22
|
+
const expiry = Date.now() + RECENT_MS;
|
|
23
|
+
for (const p of paths) {
|
|
24
|
+
if (p) {
|
|
25
|
+
recentLocal.set(norm(p), expiry);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True while a path is within the recent-local-mutation window (self-cleans on expiry). */
|
|
31
|
+
export function isRecentLocal(path: string): boolean {
|
|
32
|
+
const key = norm(path);
|
|
33
|
+
const expiry = recentLocal.get(key);
|
|
34
|
+
if (expiry === undefined) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (Date.now() > expiry) {
|
|
38
|
+
recentLocal.delete(key);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parentDir(path: string): string {
|
|
45
|
+
const i = path.lastIndexOf("/");
|
|
46
|
+
return i === -1 ? "." : path.slice(0, i);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function baseName(path: string): string {
|
|
50
|
+
const i = path.lastIndexOf("/");
|
|
51
|
+
return i === -1 ? path : path.slice(i + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Apply backend FS events to the cached directory tree in place, returning the set of directories
|
|
56
|
+
* whose contents changed. Idempotent — re-adding an existing entry or removing an absent one is a
|
|
57
|
+
* no-op, which absorbs watcher echoes. `change` events touch no tree state (name/type are stable).
|
|
58
|
+
*/
|
|
59
|
+
export function applyFsEvents(
|
|
60
|
+
dirs: Map<string, DirEntry[]>,
|
|
61
|
+
expanded: Set<string>,
|
|
62
|
+
events: FsEvent[],
|
|
63
|
+
): Set<string> {
|
|
64
|
+
const changedDirs = new Set<string>();
|
|
65
|
+
for (const ev of events) {
|
|
66
|
+
const parent = parentDir(ev.path);
|
|
67
|
+
if (ev.type === "add" || ev.type === "addDir") {
|
|
68
|
+
const entries = dirs.get(parent);
|
|
69
|
+
if (entries && !entries.some((e) => e.path === ev.path)) {
|
|
70
|
+
entries.push({
|
|
71
|
+
name: baseName(ev.path),
|
|
72
|
+
path: ev.path,
|
|
73
|
+
type: ev.isDir ? "directory" : "file",
|
|
74
|
+
});
|
|
75
|
+
changedDirs.add(parent);
|
|
76
|
+
}
|
|
77
|
+
if (ev.type === "addDir" && !dirs.has(ev.path)) {
|
|
78
|
+
dirs.set(ev.path, []);
|
|
79
|
+
}
|
|
80
|
+
} else if (ev.type === "unlink" || ev.type === "unlinkDir") {
|
|
81
|
+
const entries = dirs.get(parent);
|
|
82
|
+
if (entries) {
|
|
83
|
+
const idx = entries.findIndex((e) => e.path === ev.path);
|
|
84
|
+
if (idx !== -1) {
|
|
85
|
+
entries.splice(idx, 1);
|
|
86
|
+
changedDirs.add(parent);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (ev.type === "unlinkDir") {
|
|
90
|
+
dirs.delete(ev.path);
|
|
91
|
+
const nestedDirs: string[] = [];
|
|
92
|
+
for (const key of dirs.keys()) {
|
|
93
|
+
if (key.startsWith(`${ev.path}/`)) {
|
|
94
|
+
nestedDirs.push(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const key of nestedDirs) {
|
|
98
|
+
dirs.delete(key);
|
|
99
|
+
}
|
|
100
|
+
const staleExpanded: string[] = [];
|
|
101
|
+
for (const key of expanded) {
|
|
102
|
+
if (key === ev.path || key.startsWith(`${ev.path}/`)) {
|
|
103
|
+
staleExpanded.push(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const key of staleExpanded) {
|
|
107
|
+
expanded.delete(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return changedDirs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface FsSyncContext {
|
|
116
|
+
renderLeftPanel: () => void;
|
|
117
|
+
/** Optional hook for an external content change to an open file (e.g. reload a clean tab). */
|
|
118
|
+
onContentChange?: (path: string) => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Subscribe the sidebar to backend filesystem events. Returns an unsubscribe function; a no-op when
|
|
123
|
+
* the platform has no watcher (desktop without one, or tests). Bursts are debounced into one
|
|
124
|
+
* render.
|
|
125
|
+
*/
|
|
126
|
+
export function startFsSync(ctx: FsSyncContext): () => void {
|
|
127
|
+
const platform = getPlatform();
|
|
128
|
+
if (!platform.subscribeFileEvents) {
|
|
129
|
+
return () => {};
|
|
130
|
+
}
|
|
131
|
+
let pending: FsEvent[] = [];
|
|
132
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
133
|
+
|
|
134
|
+
const flush = () => {
|
|
135
|
+
timer = null;
|
|
136
|
+
const state = projectState;
|
|
137
|
+
const batch = pending.filter((e) => !isRecentLocal(e.path));
|
|
138
|
+
pending = [];
|
|
139
|
+
if (!state || batch.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const changedDirs = applyFsEvents(state.dirs, state.expanded, batch);
|
|
143
|
+
if (ctx.onContentChange) {
|
|
144
|
+
for (const ev of batch) {
|
|
145
|
+
if (ev.type === "change") {
|
|
146
|
+
ctx.onContentChange(ev.path);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (changedDirs.size > 0) {
|
|
151
|
+
ctx.renderLeftPanel();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return platform.subscribeFileEvents((events) => {
|
|
156
|
+
pending.push(...events);
|
|
157
|
+
if (timer) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
timer = setTimeout(flush, 50);
|
|
161
|
+
});
|
|
162
|
+
}
|
package/src/panels/dnd.ts
CHANGED
|
@@ -67,8 +67,8 @@ export function registerLayersDnD() {
|
|
|
67
67
|
for (const row of container.querySelectorAll("[data-dnd-row]") as NodeListOf<HTMLElement>) {
|
|
68
68
|
const rowPath = (row.dataset.path as string)
|
|
69
69
|
.split("/")
|
|
70
|
-
.map((s: string) => (/^\d+$/.test(s) ?
|
|
71
|
-
const rowDepth =
|
|
70
|
+
.map((s: string) => (/^\d+$/.test(s) ? Math.trunc(Number(s)) : s)) as JxPath;
|
|
71
|
+
const rowDepth = Math.trunc(Number(row.dataset.dndDepth as string)) || 0;
|
|
72
72
|
const isVoid = Object.hasOwn(row.dataset, "dndVoid");
|
|
73
73
|
const isExpanded = Object.hasOwn(row.dataset, "dndExpanded");
|
|
74
74
|
|
|
@@ -706,9 +706,9 @@ function renderSignalEditorTemplate(
|
|
|
706
706
|
: signalFieldRow("Default", defaultVal, (v: string) => {
|
|
707
707
|
let parsed: unknown = v;
|
|
708
708
|
if (def.type === "integer") {
|
|
709
|
-
parsed =
|
|
709
|
+
parsed = Math.trunc(Number(v)) || 0;
|
|
710
710
|
} else if (def.type === "number") {
|
|
711
|
-
parsed = Number
|
|
711
|
+
parsed = Number(v) || 0;
|
|
712
712
|
} else if (def.type === "boolean") {
|
|
713
713
|
parsed = v === "true";
|
|
714
714
|
} else if (def.type === "array" || def.type === "object") {
|
|
@@ -860,7 +860,7 @@ function renderDataSourceFields(
|
|
|
860
860
|
)}
|
|
861
861
|
${signalFieldRow("Version", String(def.version || 1), (v: string) =>
|
|
862
862
|
transactDoc(activeTab.value, (t) =>
|
|
863
|
-
mutateUpdateDef(t, name, { version:
|
|
863
|
+
mutateUpdateDef(t, name, { version: Math.trunc(Number(v)) || 1 }),
|
|
864
864
|
),
|
|
865
865
|
)}
|
|
866
866
|
`;
|
|
@@ -1279,6 +1279,14 @@ function resolveSchemaEnum(
|
|
|
1279
1279
|
* @param {(val: unknown) => void} onChange
|
|
1280
1280
|
* @param {Record<string, unknown>} [parentDef] - Parent def for resolving dependent enum refs
|
|
1281
1281
|
*/
|
|
1282
|
+
/** Parse a numeric field value, returning NaN for blank input (so callers can treat it as unset). */
|
|
1283
|
+
function parseNumericField(raw: string, integer: boolean): number {
|
|
1284
|
+
if (raw.trim() === "") {
|
|
1285
|
+
return Number.NaN;
|
|
1286
|
+
}
|
|
1287
|
+
return integer ? Math.trunc(Number(raw)) : Number(raw);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1282
1290
|
function renderInlineField(
|
|
1283
1291
|
key: string,
|
|
1284
1292
|
schema: Record<string, unknown>,
|
|
@@ -1319,10 +1327,10 @@ function renderInlineField(
|
|
|
1319
1327
|
.value=${value !== undefined ? value : nothing}
|
|
1320
1328
|
step=${schema.type === "integer" ? "1" : nothing}
|
|
1321
1329
|
@change=${(e: Event) => {
|
|
1322
|
-
const parsed =
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1330
|
+
const parsed = parseNumericField(
|
|
1331
|
+
(e.target as HTMLInputElement).value,
|
|
1332
|
+
schema.type === "integer",
|
|
1333
|
+
);
|
|
1326
1334
|
onChange(Number.isNaN(parsed) ? undefined : parsed);
|
|
1327
1335
|
}}
|
|
1328
1336
|
></sp-number-field>`;
|
|
@@ -1439,10 +1447,10 @@ export function renderSchemaFieldsTemplate(
|
|
|
1439
1447
|
@change=${(e: Event) => {
|
|
1440
1448
|
clearTimeout(debounce);
|
|
1441
1449
|
debounce = setTimeout(() => {
|
|
1442
|
-
const parsed =
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1450
|
+
const parsed = parseNumericField(
|
|
1451
|
+
(e.target as HTMLInputElement).value,
|
|
1452
|
+
ps.type === "integer",
|
|
1453
|
+
);
|
|
1446
1454
|
transactDoc(activeTab.value, (t) =>
|
|
1447
1455
|
mutateUpdateDef(t, name, {
|
|
1448
1456
|
[prop]: Number.isNaN(parsed) ? undefined : parsed,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
12
|
-
import type { DirEntry } from "../types";
|
|
12
|
+
import type { DirEntry, FsEvent, RenameResult } from "../types";
|
|
13
13
|
|
|
14
14
|
/** A directory entry from the server, tolerating extra wire fields. */
|
|
15
15
|
type WireDirEntry = DirEntry & Record<string, unknown>;
|
|
@@ -298,7 +298,7 @@ export function createDevServerPlatform() {
|
|
|
298
298
|
* @param {string} from
|
|
299
299
|
* @param {string} to
|
|
300
300
|
*/
|
|
301
|
-
async renameFile(from: string, to: string) {
|
|
301
|
+
async renameFile(from: string, to: string): Promise<RenameResult> {
|
|
302
302
|
const res = await fetch("/__studio/file/rename", {
|
|
303
303
|
body: JSON.stringify({ from: serverPath(from), to: serverPath(to) }),
|
|
304
304
|
headers: { "Content-Type": "application/json" },
|
|
@@ -307,6 +307,58 @@ export function createDevServerPlatform() {
|
|
|
307
307
|
if (!res.ok) {
|
|
308
308
|
throw new Error(`Failed to rename: ${from} → ${to}`);
|
|
309
309
|
}
|
|
310
|
+
const report = await readJson<RenameResult>(res);
|
|
311
|
+
// Map server-root-relative report paths back to project-relative for the studio.
|
|
312
|
+
if (typeof report.from === "string") {
|
|
313
|
+
report.from = stripRoot(report.from);
|
|
314
|
+
}
|
|
315
|
+
if (typeof report.to === "string") {
|
|
316
|
+
report.to = stripRoot(report.to);
|
|
317
|
+
}
|
|
318
|
+
for (const f of report.references?.files ?? []) {
|
|
319
|
+
f.path = stripRoot(f.path);
|
|
320
|
+
}
|
|
321
|
+
for (const e of report.errors ?? []) {
|
|
322
|
+
e.path = stripRoot(e.path);
|
|
323
|
+
}
|
|
324
|
+
return report;
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Subscribe to filesystem change events over the dev server's SSE stream. Listens for the named
|
|
329
|
+
* "fs" event (the preview iframe's default `onmessage` ignores it), strips paths to
|
|
330
|
+
* project-relative, and drops events for sibling projects outside the active root.
|
|
331
|
+
*/
|
|
332
|
+
subscribeFileEvents(handler: (events: FsEvent[]) => void) {
|
|
333
|
+
if (typeof EventSource === "undefined") {
|
|
334
|
+
return () => {};
|
|
335
|
+
}
|
|
336
|
+
const es = new EventSource("/__reload");
|
|
337
|
+
es.addEventListener("fs", (ev: MessageEvent) => {
|
|
338
|
+
let payload: { events?: FsEvent[] };
|
|
339
|
+
try {
|
|
340
|
+
payload = JSON.parse(ev.data as string) as { events?: FsEvent[] };
|
|
341
|
+
} catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const events: FsEvent[] = [];
|
|
345
|
+
for (const e of payload.events ?? []) {
|
|
346
|
+
const raw = e.path.replaceAll("\\", "/");
|
|
347
|
+
if (_projectRoot && raw !== _projectRoot && !raw.startsWith(`${_projectRoot}/`)) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const path = stripRoot(raw);
|
|
351
|
+
if (path && !path.startsWith("..")) {
|
|
352
|
+
events.push({ isDir: e.isDir, path, type: e.type });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (events.length > 0) {
|
|
356
|
+
handler(events);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return () => {
|
|
360
|
+
es.close();
|
|
361
|
+
};
|
|
310
362
|
},
|
|
311
363
|
|
|
312
364
|
/** @param {string} _path */
|
package/src/studio.ts
CHANGED
|
@@ -59,8 +59,10 @@ import {
|
|
|
59
59
|
openFileInTab,
|
|
60
60
|
openHomePage,
|
|
61
61
|
registerFileTreeDnD,
|
|
62
|
+
reloadCleanTab,
|
|
62
63
|
setupTreeKeyboard,
|
|
63
64
|
} from "./files/files";
|
|
65
|
+
import { startFsSync } from "./files/fs-events";
|
|
64
66
|
import { renderImportsTemplate } from "./panels/imports-panel";
|
|
65
67
|
import { renderHeadTemplate } from "./panels/head-panel";
|
|
66
68
|
import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
|
|
@@ -506,6 +508,13 @@ function safeRenderRightPanel() {
|
|
|
506
508
|
// Now that renderers are registered, bootstrap
|
|
507
509
|
registerFunctionCompletions();
|
|
508
510
|
|
|
511
|
+
let fsUnsub: (() => void) | null = null;
|
|
512
|
+
/** (Re)subscribe the sidebar to backend filesystem events for the active project. */
|
|
513
|
+
function ensureFsSync() {
|
|
514
|
+
fsUnsub?.();
|
|
515
|
+
fsUnsub = startFsSync({ onContentChange: reloadCleanTab, renderLeftPanel });
|
|
516
|
+
}
|
|
517
|
+
|
|
509
518
|
const _urlParams = new URLSearchParams(location.search);
|
|
510
519
|
const _projectParam = _urlParams.get("project") || _urlParams.get("open");
|
|
511
520
|
|
|
@@ -646,6 +655,7 @@ if (_projectParam) {
|
|
|
646
655
|
// Normal mode: probe for project at server root
|
|
647
656
|
void loadProject();
|
|
648
657
|
render();
|
|
658
|
+
ensureFsSync();
|
|
649
659
|
}
|
|
650
660
|
|
|
651
661
|
// ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
|
|
@@ -657,11 +667,13 @@ function renderLeftPanel() {
|
|
|
657
667
|
function loadProject() {
|
|
658
668
|
return _loadProject();
|
|
659
669
|
}
|
|
660
|
-
function openProject() {
|
|
661
|
-
|
|
670
|
+
async function openProject() {
|
|
671
|
+
const result = await _openProject({
|
|
662
672
|
renderActivityBar: () => renderActivityBar(),
|
|
663
673
|
renderLeftPanel,
|
|
664
674
|
});
|
|
675
|
+
ensureFsSync();
|
|
676
|
+
return result;
|
|
665
677
|
}
|
|
666
678
|
async function openRecentProject(root: string) {
|
|
667
679
|
try {
|
|
@@ -728,6 +740,7 @@ async function openRecentProject(root: string) {
|
|
|
728
740
|
statusMessage(`Opened project: ${requireProjectState().name}`);
|
|
729
741
|
|
|
730
742
|
await openHomePage();
|
|
743
|
+
ensureFsSync();
|
|
731
744
|
} catch (error) {
|
|
732
745
|
statusMessage(`Error: ${errorMessage(error)}`);
|
|
733
746
|
}
|
package/src/types.ts
CHANGED
|
@@ -69,6 +69,30 @@ export interface DirEntry {
|
|
|
69
69
|
modified?: string;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** A filesystem change pushed from the backend (project-relative, forward-slashed path). */
|
|
73
|
+
export interface FsEvent {
|
|
74
|
+
type: "add" | "change" | "unlink" | "addDir" | "unlinkDir";
|
|
75
|
+
path: string;
|
|
76
|
+
isDir: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Result of a rename, including the references rewritten across the project (refactor report). */
|
|
80
|
+
export interface RenameResult {
|
|
81
|
+
ok: boolean;
|
|
82
|
+
from: string;
|
|
83
|
+
to: string;
|
|
84
|
+
isDir?: boolean;
|
|
85
|
+
references?: {
|
|
86
|
+
filesChanged: number;
|
|
87
|
+
refsUpdated: number;
|
|
88
|
+
files: { path: string; count: number }[];
|
|
89
|
+
};
|
|
90
|
+
errors?: { path: string; error: string }[];
|
|
91
|
+
tag?: { from: string; to: string; filesChanged: number; refsUpdated: number };
|
|
92
|
+
tagSkipped?: string;
|
|
93
|
+
error?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
72
96
|
export interface StudioPlatform {
|
|
73
97
|
id: string;
|
|
74
98
|
projectRoot: string;
|
|
@@ -90,8 +114,13 @@ export interface StudioPlatform {
|
|
|
90
114
|
writeFile: (path: string, content: string) => Promise<void>;
|
|
91
115
|
uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<unknown>;
|
|
92
116
|
deleteFile: (path: string) => Promise<void>;
|
|
93
|
-
renameFile: (from: string, to: string) => Promise<
|
|
117
|
+
renameFile: (from: string, to: string) => Promise<RenameResult>;
|
|
94
118
|
createDirectory: (path: string) => Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* Subscribe to backend filesystem change events for the active project. Returns an unsubscribe
|
|
121
|
+
* function. Optional: platforms without a watcher omit it and the sidebar stays manual-refresh.
|
|
122
|
+
*/
|
|
123
|
+
subscribeFileEvents?: (handler: (events: FsEvent[]) => void) => () => void;
|
|
95
124
|
discoverComponents: (dir?: string) => Promise<ComponentMeta[]>;
|
|
96
125
|
addPackage: (name: string) => Promise<unknown>;
|
|
97
126
|
removePackage: (name: string) => Promise<unknown>;
|
package/src/ui/panel-resize.ts
CHANGED
|
@@ -16,6 +16,11 @@ const DEFAULT_RIGHT = 280;
|
|
|
16
16
|
|
|
17
17
|
const root = document.documentElement;
|
|
18
18
|
|
|
19
|
+
/** Read a px-valued CSS custom property as a number (e.g. "320px" → 320). */
|
|
20
|
+
function readPxVar(cssVar: string): number {
|
|
21
|
+
return Number(getComputedStyle(root).getPropertyValue(cssVar).replace(/px$/, ""));
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
// ─── Restore saved widths & collapse state ──────────────────────────────────
|
|
20
25
|
|
|
21
26
|
try {
|
|
@@ -68,8 +73,7 @@ function setupHandle(
|
|
|
68
73
|
handle.classList.add("dragging");
|
|
69
74
|
document.body.style.userSelect = "none";
|
|
70
75
|
|
|
71
|
-
const current =
|
|
72
|
-
Number.parseInt(getComputedStyle(root).getPropertyValue(cssVar), 10) || defaultWidth;
|
|
76
|
+
const current = readPxVar(cssVar) || defaultWidth;
|
|
73
77
|
drag = { startWidth: current, startX: e.clientX };
|
|
74
78
|
});
|
|
75
79
|
|
|
@@ -105,11 +109,8 @@ function setupHandle(
|
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
function persistWidths() {
|
|
108
|
-
const left =
|
|
109
|
-
|
|
110
|
-
const right =
|
|
111
|
-
Number.parseInt(getComputedStyle(root).getPropertyValue("--panel-w-right"), 10) ||
|
|
112
|
-
DEFAULT_RIGHT;
|
|
112
|
+
const left = readPxVar("--panel-w-left") || DEFAULT_LEFT;
|
|
113
|
+
const right = readPxVar("--panel-w-right") || DEFAULT_RIGHT;
|
|
113
114
|
try {
|
|
114
115
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
|
|
115
116
|
} catch {
|
package/src/ui/unit-selector.ts
CHANGED
|
@@ -44,6 +44,9 @@ export function renderUnitSelector(
|
|
|
44
44
|
} else if (match) {
|
|
45
45
|
displayValue = match[1]!;
|
|
46
46
|
} else if (strVal !== "") {
|
|
47
|
+
// Intentional partial parse: shorthand like "10px 20px" displays its leading number.
|
|
48
|
+
// Number() yields NaN for those, so Number.parseFloat stays (see unit-selector tests).
|
|
49
|
+
// oxlint-disable-next-line unicorn/prefer-number-coercion
|
|
47
50
|
const num = Number.parseFloat(strVal);
|
|
48
51
|
displayValue = Number.isNaN(num) ? strVal : String(num);
|
|
49
52
|
} else {
|
|
@@ -25,15 +25,15 @@ export function parseMediaEntries(mediaDef?: Record<string, string> | null) {
|
|
|
25
25
|
for (const [name, query] of Object.entries(mediaDef)) {
|
|
26
26
|
if (name === "--") {
|
|
27
27
|
const wm = String(query).match(/^(\d+)\s*px$/);
|
|
28
|
-
baseWidth = wm ? Number
|
|
28
|
+
baseWidth = wm ? Number(wm[1]!) : 320;
|
|
29
29
|
continue;
|
|
30
30
|
}
|
|
31
31
|
const minMatch = query.match(/min-width:\s*([\d.]+)px/);
|
|
32
32
|
const maxMatch = query.match(/max-width:\s*([\d.]+)px/);
|
|
33
33
|
if (minMatch) {
|
|
34
|
-
sizes.push({ name, query, type: "min", width: Number
|
|
34
|
+
sizes.push({ name, query, type: "min", width: Number(minMatch[1]!) });
|
|
35
35
|
} else if (maxMatch) {
|
|
36
|
-
sizes.push({ name, query, type: "max", width: Number
|
|
36
|
+
sizes.push({ name, query, type: "max", width: Number(maxMatch[1]!) });
|
|
37
37
|
} else {
|
|
38
38
|
features.push({ name, query });
|
|
39
39
|
}
|