@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.
- package/dist/studio.css +98 -98
- package/dist/studio.js +4628 -2543
- package/dist/studio.js.map +58 -32
- 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 +13 -11
- package/src/about/about-modal.ts +156 -0
- package/src/editor/context-menu.ts +4 -2
- package/src/files/files.ts +50 -18
- package/src/files/fs-events.ts +162 -0
- package/src/packages/ensure-deps.ts +39 -0
- package/src/packages/jxsuite-update.ts +131 -0
- package/src/packages/semver.ts +54 -0
- package/src/panels/activity-bar.ts +13 -1
- package/src/panels/ai-panel.ts +3 -1
- package/src/panels/dnd.ts +2 -2
- package/src/panels/elements-panel.ts +2 -1
- package/src/panels/layers-panel.ts +5 -1
- package/src/panels/preview-render.ts +1 -1
- package/src/panels/properties-panel.ts +20 -14
- package/src/panels/signals-panel.ts +29 -18
- package/src/panels/style-panel.ts +2 -2
- package/src/panels/stylebook-layers-panel.ts +5 -5
- package/src/panels/stylebook-panel.ts +1 -1
- package/src/platforms/devserver.ts +92 -2
- package/src/settings/dependencies-editor.ts +262 -0
- package/src/settings/general-settings.ts +5 -3
- package/src/settings/settings-modal.ts +6 -0
- package/src/studio.ts +21 -2
- package/src/tabs/tab.ts +10 -4
- package/src/types.ts +82 -1
- package/src/ui/expression-editor.ts +4 -3
- package/src/ui/field-input.ts +1 -1
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +8 -7
- package/src/ui/progress-modal.ts +94 -0
- package/src/ui/spectrum.ts +4 -0
- package/src/ui/unit-selector.ts +3 -0
- package/src/utils/canvas-media.ts +3 -3
- package/src/version.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/studio",
|
|
3
|
-
"version": "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
|
|
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
|
-
"
|
|
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": "^
|
|
35
|
-
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^
|
|
36
|
-
"@jxsuite/parser": "^0.
|
|
37
|
-
"@jxsuite/runtime": "^0.
|
|
38
|
-
"@jxsuite/schema": "^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.
|
|
84
|
+
"@happy-dom/global-registrator": "^20.10.6",
|
|
83
85
|
"@types/mdast": "^4.0.4",
|
|
84
|
-
"@webref/css": "^8.
|
|
86
|
+
"@webref/css": "^8.6.0",
|
|
85
87
|
"@webref/elements": "^2.7.1",
|
|
86
|
-
"@webref/idl": "^3.81.
|
|
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]:
|
|
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,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(
|
|
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
|
+
}
|