@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.29.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 build ./node_modules/monaco-editor/esm/vs/editor/editor.worker.js --outdir dist/workers --target browser --entry-naming editor.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/json/json.worker.js --outdir dist/workers --target browser --entry-naming json.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js --outdir dist/workers --target browser --entry-naming ts.worker.js",
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": "^1.8.1",
35
- "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
36
- "@jxsuite/parser": "^0.29.0",
37
- "@jxsuite/runtime": "^0.29.0",
38
- "@jxsuite/schema": "^0.29.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.2",
82
+ "@happy-dom/global-registrator": "^20.10.6",
83
83
  "@types/mdast": "^4.0.4",
84
- "@webref/css": "^8.5.8",
84
+ "@webref/css": "^8.6.0",
85
85
  "@webref/elements": "^2.7.1",
86
- "@webref/idl": "^3.81.0"
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]: new Blob([JSON.stringify(json)], { type: JX_MIME }),
76
- "text/html": new Blob([nodeToHtml(json)], { type: "text/html" }),
77
+ [JX_MIME]: jxBlob,
78
+ "text/html": htmlBlob,
77
79
  }),
78
80
  ]);
79
81
  } catch {
@@ -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(`Renamed to ${newName}`);
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) ? Number.parseInt(s, 10) : s)) as JxPath;
71
- const rowDepth = Number.parseInt(row.dataset.dndDepth as string, 10) || 0;
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 = Number.parseInt(v, 10) || 0;
709
+ parsed = Math.trunc(Number(v)) || 0;
710
710
  } else if (def.type === "number") {
711
- parsed = Number.parseFloat(v) || 0;
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: Number.parseInt(v, 10) || 1 }),
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
- schema.type === "integer"
1324
- ? Number.parseInt((e.target as HTMLInputElement).value, 10)
1325
- : Number.parseFloat((e.target as HTMLInputElement).value);
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
- ps.type === "integer"
1444
- ? Number.parseInt((e.target as HTMLInputElement).value, 10)
1445
- : Number.parseFloat((e.target as HTMLInputElement).value);
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
- return _openProject({
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<void>;
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>;
@@ -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
- Number.parseInt(getComputedStyle(root).getPropertyValue("--panel-w-left"), 10) || DEFAULT_LEFT;
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 {
@@ -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.parseFloat(wm[1]!) : 320;
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.parseFloat(minMatch[1]!) });
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.parseFloat(maxMatch[1]!) });
36
+ sizes.push({ name, query, type: "max", width: Number(maxMatch[1]!) });
37
37
  } else {
38
38
  features.push({ name, query });
39
39
  }