@jxsuite/studio 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +4628 -2543
  3. package/dist/studio.js.map +58 -32
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +13 -11
  8. package/src/about/about-modal.ts +156 -0
  9. package/src/editor/context-menu.ts +4 -2
  10. package/src/files/files.ts +50 -18
  11. package/src/files/fs-events.ts +162 -0
  12. package/src/packages/ensure-deps.ts +39 -0
  13. package/src/packages/jxsuite-update.ts +131 -0
  14. package/src/packages/semver.ts +54 -0
  15. package/src/panels/activity-bar.ts +13 -1
  16. package/src/panels/ai-panel.ts +3 -1
  17. package/src/panels/dnd.ts +2 -2
  18. package/src/panels/elements-panel.ts +2 -1
  19. package/src/panels/layers-panel.ts +5 -1
  20. package/src/panels/preview-render.ts +1 -1
  21. package/src/panels/properties-panel.ts +20 -14
  22. package/src/panels/signals-panel.ts +29 -18
  23. package/src/panels/style-panel.ts +2 -2
  24. package/src/panels/stylebook-layers-panel.ts +5 -5
  25. package/src/panels/stylebook-panel.ts +1 -1
  26. package/src/platforms/devserver.ts +92 -2
  27. package/src/settings/dependencies-editor.ts +262 -0
  28. package/src/settings/general-settings.ts +5 -3
  29. package/src/settings/settings-modal.ts +6 -0
  30. package/src/studio.ts +21 -2
  31. package/src/tabs/tab.ts +10 -4
  32. package/src/types.ts +82 -1
  33. package/src/ui/expression-editor.ts +4 -3
  34. package/src/ui/field-input.ts +1 -1
  35. package/src/ui/media-picker.ts +1 -1
  36. package/src/ui/panel-resize.ts +8 -7
  37. package/src/ui/progress-modal.ts +94 -0
  38. package/src/ui/spectrum.ts +4 -0
  39. package/src/ui/unit-selector.ts +3 -0
  40. package/src/utils/canvas-media.ts +3 -3
  41. package/src/version.ts +34 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,19 +23,20 @@
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 run scripts/build.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
- "test": "bun test --isolate",
29
+ "lint:styles": "bun run scripts/check-styles.ts",
30
+ "test": "bun run lint:styles && bun test --isolate",
30
31
  "test:coverage": "bun test --isolate --coverage",
31
32
  "upgrade": "bunx npm-check-updates -u && bun install"
32
33
  },
33
34
  "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",
35
+ "@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
36
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
37
+ "@jxsuite/parser": "^0.31.0",
38
+ "@jxsuite/runtime": "^0.31.0",
39
+ "@jxsuite/schema": "^0.31.0",
39
40
  "@spectrum-web-components/accordion": "^1.12.1",
40
41
  "@spectrum-web-components/action-bar": "1.12.1",
41
42
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -61,6 +62,7 @@
61
62
  "@spectrum-web-components/picker": "^1.12.1",
62
63
  "@spectrum-web-components/picker-button": "^1.12.1",
63
64
  "@spectrum-web-components/popover": "^1.12.1",
65
+ "@spectrum-web-components/progress-circle": "^1.12.1",
64
66
  "@spectrum-web-components/search": "^1.12.1",
65
67
  "@spectrum-web-components/swatch": "^1.12.1",
66
68
  "@spectrum-web-components/switch": "^1.12.1",
@@ -79,11 +81,11 @@
79
81
  "yaml": "^2.9.0"
80
82
  },
81
83
  "devDependencies": {
82
- "@happy-dom/global-registrator": "^20.10.2",
84
+ "@happy-dom/global-registrator": "^20.10.6",
83
85
  "@types/mdast": "^4.0.4",
84
- "@webref/css": "^8.5.8",
86
+ "@webref/css": "^8.6.0",
85
87
  "@webref/elements": "^2.7.1",
86
- "@webref/idl": "^3.81.0"
88
+ "@webref/idl": "^3.81.1"
87
89
  },
88
90
  "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
89
91
  }
@@ -0,0 +1,156 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * About modal — shows app version, build metadata, external links, the resolved `@jxsuite/*`
4
+ * package versions, and (on desktop) the release channel / update status.
5
+ *
6
+ * Build metadata comes from src/version.ts (injected at bundle time). Package versions are fetched
7
+ * lazily via the platform; desktop update info is shown only when the active platform implements
8
+ * the optional `getAppInfo` method.
9
+ */
10
+
11
+ import { html } from "lit-html";
12
+ import type { TemplateResult } from "lit-html";
13
+ import { openModal } from "../ui/layers";
14
+ import { getPlatform } from "../platform";
15
+ import { APP_NAME, BUILD_DATE, GIT_COMMIT, LINKS, VERSION } from "../version";
16
+ import type { AppInfo, PackageInfo } from "../types";
17
+
18
+ let _handle: ReturnType<typeof openModal> | null = null;
19
+
20
+ let _packages: PackageInfo[] | null = null;
21
+ let _appInfo: AppInfo | null = null;
22
+
23
+ export function openAboutModal() {
24
+ if (_handle) {
25
+ return;
26
+ }
27
+ _packages = null;
28
+ _appInfo = null;
29
+ renderModal();
30
+ void loadDetails();
31
+ }
32
+
33
+ export function closeAboutModal() {
34
+ if (!_handle) {
35
+ return;
36
+ }
37
+ _handle.close();
38
+ _handle = null;
39
+ _packages = null;
40
+ _appInfo = null;
41
+ }
42
+
43
+ async function loadDetails() {
44
+ const platform = getPlatform();
45
+ try {
46
+ _packages = await platform.listPackages();
47
+ } catch {
48
+ _packages = [];
49
+ }
50
+ if (platform.getAppInfo) {
51
+ try {
52
+ _appInfo = await platform.getAppInfo();
53
+ } catch {
54
+ _appInfo = null;
55
+ }
56
+ }
57
+ if (_handle) {
58
+ renderModal();
59
+ }
60
+ }
61
+
62
+ function formatBuildDate(iso: string) {
63
+ if (!iso) {
64
+ return "—";
65
+ }
66
+ const date = new Date(iso);
67
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
68
+ }
69
+
70
+ function metaRows() {
71
+ const rows: [string, string][] = [
72
+ ["Version", VERSION],
73
+ ["Build date", formatBuildDate(BUILD_DATE)],
74
+ ["Commit", GIT_COMMIT],
75
+ ];
76
+ if (_appInfo) {
77
+ rows.push(["Channel", _appInfo.channel]);
78
+ if (_appInfo.updateStatus) {
79
+ rows.push(["Updates", _appInfo.updateStatus]);
80
+ }
81
+ }
82
+ return rows;
83
+ }
84
+
85
+ function renderPackages(): TemplateResult {
86
+ if (_packages === null) {
87
+ return html`<p class="about-muted">Loading packages…</p>`;
88
+ }
89
+ if (_packages.length === 0) {
90
+ return html`<p class="about-muted">No packages reported.</p>`;
91
+ }
92
+ return html`
93
+ <ul class="about-packages">
94
+ ${_packages.map(
95
+ (p) => html`
96
+ <li class="about-package-row">
97
+ <span class="about-package-name">${p.name}</span>
98
+ <span class="about-package-version">${p.version}</span>
99
+ </li>
100
+ `,
101
+ )}
102
+ </ul>
103
+ `;
104
+ }
105
+
106
+ function renderModal() {
107
+ const tpl = html`
108
+ <sp-underlay open @close=${closeAboutModal}></sp-underlay>
109
+ <div
110
+ class="about-modal"
111
+ role="dialog"
112
+ aria-label="About ${APP_NAME}"
113
+ @keydown=${(e: KeyboardEvent) => {
114
+ if (e.key === "Escape") {
115
+ closeAboutModal();
116
+ }
117
+ }}
118
+ >
119
+ <div class="settings-modal-header">
120
+ <h2 class="settings-modal-title">About ${APP_NAME}</h2>
121
+ <sp-action-button quiet size="s" @click=${closeAboutModal} title="Close">
122
+ <sp-icon-close slot="icon"></sp-icon-close>
123
+ </sp-action-button>
124
+ </div>
125
+ <div class="about-modal-body">
126
+ <dl class="about-meta">
127
+ ${metaRows().map(
128
+ ([label, value]) => html`
129
+ <div class="about-meta-row">
130
+ <dt class="about-meta-label">${label}</dt>
131
+ <dd class="about-meta-value">${value}</dd>
132
+ </div>
133
+ `,
134
+ )}
135
+ </dl>
136
+
137
+ <div class="about-links">
138
+ <a href=${LINKS.github} target="_blank" rel="noreferrer noopener">GitHub</a>
139
+ <a href=${LINKS.docs} target="_blank" rel="noreferrer noopener">Documentation</a>
140
+ <a href=${LINKS.license} target="_blank" rel="noreferrer noopener">License</a>
141
+ </div>
142
+
143
+ <section class="about-section">
144
+ <h3 class="about-section-title">Packages</h3>
145
+ ${renderPackages()}
146
+ </section>
147
+ </div>
148
+ </div>
149
+ `;
150
+
151
+ if (_handle) {
152
+ _handle.update(tpl);
153
+ } else {
154
+ _handle = openModal(tpl);
155
+ }
156
+ }
@@ -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,20 +16,16 @@ 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 { ensureDependenciesInstalled } from "../packages/ensure-deps";
20
+ import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
21
+ import { markLocalMutation } from "./fs-events";
19
22
  import {
20
23
  draggable,
21
24
  dropTargetForElements,
22
25
  monitorForElements,
23
26
  } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
24
27
  import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
25
- import {
26
- activateTab,
27
- activeTab,
28
- openTab,
29
- renameTab,
30
- replaceAllTabs,
31
- workspace,
32
- } from "../workspace/workspace";
28
+ import { activateTab, openTab, renameTab, replaceAllTabs, workspace } from "../workspace/workspace";
33
29
  import { parseSourceForPath, serializeDocument } from "./file-ops";
34
30
  import {
35
31
  documentExtensions,
@@ -44,6 +40,7 @@ import type { TemplateResult } from "lit-html";
44
40
  import type { JxMutableNode } from "@jxsuite/schema/types";
45
41
  import type { StudioState } from "../state.js";
46
42
  import type { Tab } from "../tabs/tab.js";
43
+ import type { RenameResult } from "../types";
47
44
 
48
45
  // ─── File icon map ────────────────────────────────────────────────────────────
49
46
 
@@ -98,9 +95,11 @@ export async function loadProject() {
98
95
  });
99
96
 
100
97
  if (info.isSiteProject) {
98
+ await ensureDependenciesInstalled();
101
99
  await loadDirectory(".");
102
100
  await loadComponentRegistry();
103
101
  await openHomePage();
102
+ void maybePromptJxsuiteUpdate(meta.root);
104
103
  }
105
104
  // If not a site project (monorepo) — show welcome prompt, don't load tree
106
105
  } catch {
@@ -154,6 +153,7 @@ export async function openProject({
154
153
  selectedPath: null,
155
154
  });
156
155
 
156
+ await ensureDependenciesInstalled();
157
157
  await loadDirectory(".");
158
158
  await loadComponentRegistry();
159
159
 
@@ -185,6 +185,7 @@ export async function openProject({
185
185
  statusMessage(`Opened project: ${requireProjectState().name}`);
186
186
 
187
187
  await openHomePage();
188
+ void maybePromptJxsuiteUpdate(requireProjectState().projectRoot);
188
189
  } catch (error) {
189
190
  statusMessage(`Error: ${errorMessage(error)}`);
190
191
  }
@@ -636,8 +637,9 @@ export function registerFileTreeDnD({ renderLeftPanel }: { renderLeftPanel: () =
636
637
  */
637
638
  async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel: () => void) {
638
639
  const platform = getPlatform();
640
+ markLocalMutation(oldPath, newPath);
639
641
  try {
640
- await platform.renameFile(oldPath, newPath);
642
+ const report = await platform.renameFile(oldPath, newPath);
641
643
 
642
644
  // Update open tabs referencing the moved path
643
645
  for (const [id] of workspace.tabs.entries()) {
@@ -662,6 +664,7 @@ async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel:
662
664
  requireProjectState().expanded.add(newParent);
663
665
  }
664
666
 
667
+ reloadRewrittenTabs(report, newPath);
665
668
  renderLeftPanel();
666
669
  statusMessage(`Moved to ${newPath}`);
667
670
  } catch (error) {
@@ -779,6 +782,7 @@ async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
779
782
  return;
780
783
  }
781
784
  const path = dirPath === "." ? name : `${dirPath}/${name}`;
785
+ markLocalMutation(path);
782
786
  await loadFormats();
783
787
  const format = formatForPath(name);
784
788
  const content =
@@ -849,6 +853,28 @@ function showRenameFileDialog(currentName: string): Promise<string | null> {
849
853
  });
850
854
  }
851
855
 
856
+ /** Build the status-bar message for a rename, summarising any reference/tag rewrites. */
857
+ function renameStatus(newName: string, report: RenameResult): string {
858
+ const refs = report.references;
859
+ const tagNote = report.tag ? `; tag → <${report.tag.to}> (${report.tag.refsUpdated})` : "";
860
+ if (refs && refs.refsUpdated > 0) {
861
+ return `Renamed to ${newName}; updated ${refs.refsUpdated} reference(s) in ${refs.filesChanged} file(s)${tagNote}`;
862
+ }
863
+ if (tagNote) {
864
+ return `Renamed to ${newName}${tagNote}`;
865
+ }
866
+ return `Renamed to ${newName}`;
867
+ }
868
+
869
+ /** Reload any open tabs whose references the refactor rewrote (so the editor shows new paths). */
870
+ function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
871
+ for (const f of report.references?.files ?? []) {
872
+ if (f.path !== skipPath && workspace.tabs.has(f.path)) {
873
+ void reloadFileInTab(f.path);
874
+ }
875
+ }
876
+ }
877
+
852
878
  async function renameFile(
853
879
  entry: { name: string; path: string; type: string },
854
880
  renderLeftPanel: () => void,
@@ -862,9 +888,10 @@ async function renameFile(
862
888
  ? entryPath.slice(0, entryPath.lastIndexOf("/"))
863
889
  : ".";
864
890
  const newPath = parentDirPath === "." ? newName : `${parentDirPath}/${newName}`;
891
+ markLocalMutation(entry.path, newPath);
865
892
  try {
866
893
  const platform = getPlatform();
867
- await platform.renameFile(entry.path, newPath);
894
+ const report = await platform.renameFile(entry.path, newPath);
868
895
  await loadDirectory(parentDirPath);
869
896
  if (requireProjectState().selectedPath === entry.path) {
870
897
  requireProjectState().selectedPath = newPath;
@@ -872,8 +899,9 @@ async function renameFile(
872
899
  if (workspace.tabs.has(entry.path)) {
873
900
  renameTab(entry.path, newPath, newPath);
874
901
  }
902
+ reloadRewrittenTabs(report, newPath);
875
903
  renderLeftPanel();
876
- statusMessage(`Renamed to ${newName}`);
904
+ statusMessage(renameStatus(newName, report));
877
905
  } catch (error) {
878
906
  statusMessage(`Error: ${errorMessage(error)}`);
879
907
  }
@@ -892,6 +920,7 @@ async function deleteFile(
892
920
  }
893
921
  try {
894
922
  const platform = getPlatform();
923
+ markLocalMutation(entry.path);
895
924
  await platform.deleteFile(entry.path);
896
925
  const delPath = entry.path.replaceAll("\\", "/");
897
926
  const parentDirPath = delPath.includes("/") ? delPath.slice(0, delPath.lastIndexOf("/")) : ".";
@@ -1026,13 +1055,6 @@ export async function openFileInTab(path: string) {
1026
1055
  requireProjectState().selectedPath = path;
1027
1056
  trackRecentFile({ name: path.split("/").pop() || path, path });
1028
1057
 
1029
- if (path === "project.json") {
1030
- const tab = activeTab.value;
1031
- if (tab) {
1032
- tab.session.ui.canvasMode = "stylebook";
1033
- }
1034
- }
1035
-
1036
1058
  statusMessage(`Opened ${path.split("/").pop()}`);
1037
1059
  } catch (error) {
1038
1060
  statusMessage(`Error: ${errorMessage(error)}`);
@@ -1045,6 +1067,16 @@ export async function openFileInTab(path: string) {
1045
1067
  *
1046
1068
  * @param {string} path
1047
1069
  */
1070
+ /** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
1071
+ export function reloadCleanTab(path: string): void {
1072
+ for (const [, tab] of workspace.tabs.entries()) {
1073
+ if (tab.documentPath === path && !tab.doc.dirty) {
1074
+ void reloadFileInTab(path);
1075
+ return;
1076
+ }
1077
+ }
1078
+ }
1079
+
1048
1080
  export async function reloadFileInTab(path: string) {
1049
1081
  for (const [, tab] of workspace.tabs.entries()) {
1050
1082
  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
+ }
@@ -0,0 +1,39 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ensure the active project's dependencies are installed before the editor loads. When the backend
4
+ * reports node_modules is missing, run `bun install` behind a blocking progress modal so component
5
+ * and format resolution see the real dependency tree. No-op on platforms without package support.
6
+ */
7
+
8
+ import { getPlatform } from "../platform";
9
+ import { showProgressModal } from "../ui/progress-modal";
10
+
11
+ export async function ensureDependenciesInstalled(): Promise<void> {
12
+ const platform = getPlatform();
13
+ if (!platform.dependenciesNeedInstall || !platform.installDependencies) {
14
+ return;
15
+ }
16
+ let needs = false;
17
+ try {
18
+ needs = await platform.dependenciesNeedInstall();
19
+ } catch {
20
+ return;
21
+ }
22
+ if (!needs) {
23
+ return;
24
+ }
25
+ const progress = showProgressModal({
26
+ status: "Running bun install…",
27
+ title: "Installing dependencies",
28
+ });
29
+ try {
30
+ const result = await platform.installDependencies();
31
+ if (result.ok) {
32
+ progress.done();
33
+ } else {
34
+ progress.fail(result.log ?? "bun install failed");
35
+ }
36
+ } catch (error) {
37
+ progress.fail(error instanceof Error ? error.message : String(error));
38
+ }
39
+ }