@jxsuite/server 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 +9 -8
- package/src/refactor/apply.ts +215 -0
- package/src/refactor/fs-events.ts +71 -0
- package/src/refactor/index.ts +11 -0
- package/src/refactor/paths.ts +187 -0
- package/src/refactor/refs.ts +236 -0
- package/src/refactor/watcher.ts +70 -0
- package/src/server.ts +6 -3
- package/src/studio-api.ts +27 -12
- package/src/watch.ts +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/server.ts",
|
|
17
17
|
"./claude-session": "./src/claude-session.ts",
|
|
18
|
+
"./refactor": "./src/refactor/index.ts",
|
|
18
19
|
"./resolve": "./src/resolve.ts",
|
|
19
20
|
"./types": "./src/types.ts"
|
|
20
21
|
},
|
|
@@ -27,13 +28,13 @@
|
|
|
27
28
|
"test:coverage": "bun test --isolate --coverage"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
31
|
-
"@anthropic-ai/sdk": "^0.
|
|
32
|
-
"@jxsuite/compiler": "^0.
|
|
33
|
-
"@jxsuite/create": "^0.
|
|
34
|
-
"@jxsuite/parser": "^0.
|
|
35
|
-
"@jxsuite/runtime": "^0.
|
|
36
|
-
"@jxsuite/schema": "^0.
|
|
31
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.186",
|
|
32
|
+
"@anthropic-ai/sdk": "^0.105.0",
|
|
33
|
+
"@jxsuite/compiler": "^0.30.1",
|
|
34
|
+
"@jxsuite/create": "^0.30.1",
|
|
35
|
+
"@jxsuite/parser": "^0.30.1",
|
|
36
|
+
"@jxsuite/runtime": "^0.30.1",
|
|
37
|
+
"@jxsuite/schema": "^0.30.1",
|
|
37
38
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
39
|
"chokidar": "^5.0.0",
|
|
39
40
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply.ts — IO orchestrator for rename-refactoring, shared by the dev-server endpoint
|
|
3
|
+
* (studio-api.ts) and the desktop project session.
|
|
4
|
+
*
|
|
5
|
+
* The caller performs the filesystem move first, then calls `applyRename`, which globs the
|
|
6
|
+
* project's document + content files, runs the pure engines from refs.ts against each, writes back
|
|
7
|
+
* only the files that changed, and returns a `RenameReport`. When the renamed file is a component,
|
|
8
|
+
* the tag is auto-derived from the new filename and renamed everywhere it is used (Pillar C).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
12
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
14
|
+
import { isUnder } from "./paths.ts";
|
|
15
|
+
import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
|
|
16
|
+
import type { RefChange } from "./refs.ts";
|
|
17
|
+
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
18
|
+
|
|
19
|
+
/** Normalise a path to forward slashes. */
|
|
20
|
+
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
21
|
+
|
|
22
|
+
export interface FileChange {
|
|
23
|
+
path: string;
|
|
24
|
+
count: number;
|
|
25
|
+
changes: RefChange[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RenameReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
isDir: boolean;
|
|
33
|
+
references: {
|
|
34
|
+
filesChanged: number;
|
|
35
|
+
refsUpdated: number;
|
|
36
|
+
files: FileChange[];
|
|
37
|
+
};
|
|
38
|
+
errors: { path: string; error: string }[];
|
|
39
|
+
tag?: { from: string; to: string; filesChanged: number; refsUpdated: number };
|
|
40
|
+
tagSkipped?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ApplyRenameOptions {
|
|
44
|
+
/** Absolute project root to scan. */
|
|
45
|
+
root: string;
|
|
46
|
+
/** Absolute path of the renamed file/dir, before the move. */
|
|
47
|
+
absFrom: string;
|
|
48
|
+
/** Absolute path of the renamed file/dir, after the move (already on disk). */
|
|
49
|
+
absTo: string;
|
|
50
|
+
/** Format registry for the project (parse/serialize of non-JSON documents). */
|
|
51
|
+
registry: FormatRegistry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A parsed document plus the serializer that round-trips it back to disk. */
|
|
55
|
+
interface LoadedDoc {
|
|
56
|
+
doc: unknown;
|
|
57
|
+
serialize: ((doc: unknown) => Promise<string>) | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isJsonPath = (p: string) => p.endsWith(".json");
|
|
61
|
+
|
|
62
|
+
/** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
|
|
63
|
+
function skip(match: string): boolean {
|
|
64
|
+
const f = fwd(match);
|
|
65
|
+
return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Derive a custom-element tag from a component filename (strips `.class.json` or the extension). */
|
|
69
|
+
export function deriveTag(absPath: string): string {
|
|
70
|
+
const base = basename(absPath);
|
|
71
|
+
if (base.endsWith(".class.json")) {
|
|
72
|
+
return base.slice(0, -".class.json".length);
|
|
73
|
+
}
|
|
74
|
+
const ext = extname(base);
|
|
75
|
+
return ext ? base.slice(0, -ext.length) : base;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read + parse a file, returning the doc and a matching serializer (null when none is available). */
|
|
79
|
+
async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
|
|
80
|
+
const raw = await readFile(fp, "utf8");
|
|
81
|
+
if (isJsonPath(fp)) {
|
|
82
|
+
const trailingNewline = raw.endsWith("\n");
|
|
83
|
+
return {
|
|
84
|
+
doc: JSON.parse(raw) as unknown,
|
|
85
|
+
serialize: (doc) =>
|
|
86
|
+
Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const ext = extname(fp);
|
|
90
|
+
const parseEntry = registry.byExtension(ext, "parse");
|
|
91
|
+
if (!parseEntry) {
|
|
92
|
+
throw new Error(`No parser for "${ext}"`);
|
|
93
|
+
}
|
|
94
|
+
const doc = await parseEntry.call("parse", raw);
|
|
95
|
+
const serializeEntry = registry.byExtension(ext, "serialize");
|
|
96
|
+
return {
|
|
97
|
+
doc,
|
|
98
|
+
serialize: serializeEntry
|
|
99
|
+
? async (d) => {
|
|
100
|
+
const out = await serializeEntry.call("serialize", d);
|
|
101
|
+
return typeof out === "string" ? out : String(out);
|
|
102
|
+
}
|
|
103
|
+
: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build the recursive glob covering JSON plus every registered document/content extension. */
|
|
108
|
+
function documentGlob(registry: FormatRegistry) {
|
|
109
|
+
const exts = new Set(["json"]);
|
|
110
|
+
for (const ext of registry.documentExtensions()) {
|
|
111
|
+
exts.add(ext.replace(/^\./, ""));
|
|
112
|
+
}
|
|
113
|
+
return new Bun.Glob(`**/*.{${[...exts].join(",")}}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Rewrite references (and, for components, the tag) across the project after a rename. The caller
|
|
118
|
+
* must have already moved the file on disk.
|
|
119
|
+
*/
|
|
120
|
+
export async function applyRename(opts: ApplyRenameOptions): Promise<RenameReport> {
|
|
121
|
+
const root = fwd(opts.root);
|
|
122
|
+
const absFrom = fwd(opts.absFrom);
|
|
123
|
+
const absTo = fwd(opts.absTo);
|
|
124
|
+
|
|
125
|
+
let isDir = false;
|
|
126
|
+
try {
|
|
127
|
+
const stats = await stat(absTo);
|
|
128
|
+
isDir = stats.isDirectory();
|
|
129
|
+
} catch {
|
|
130
|
+
isDir = false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const report: RenameReport = {
|
|
134
|
+
errors: [],
|
|
135
|
+
from: fwd(relative(opts.root, opts.absFrom)),
|
|
136
|
+
isDir,
|
|
137
|
+
ok: true,
|
|
138
|
+
references: { files: [], filesChanged: 0, refsUpdated: 0 },
|
|
139
|
+
to: fwd(relative(opts.root, opts.absTo)),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// For a component file rename, derive the new tag from the new filename (Pillar C).
|
|
143
|
+
let tagRename: { from: string; to: string } | null = null;
|
|
144
|
+
if (!isDir) {
|
|
145
|
+
try {
|
|
146
|
+
const { doc } = await loadDoc(absTo, opts.registry);
|
|
147
|
+
const oldTag = (doc as { tagName?: unknown }).tagName;
|
|
148
|
+
const newTag = deriveTag(absTo);
|
|
149
|
+
if (typeof oldTag === "string" && oldTag.includes("-") && newTag !== oldTag) {
|
|
150
|
+
if (newTag.includes("-")) {
|
|
151
|
+
tagRename = { from: oldTag, to: newTag };
|
|
152
|
+
} else {
|
|
153
|
+
report.tagSkipped = `Tag "${oldTag}" left unchanged: "${newTag}" is not a valid custom-element name`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Not a parseable component — skip tag derivation.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let tagFilesChanged = 0;
|
|
162
|
+
let tagRefsUpdated = 0;
|
|
163
|
+
|
|
164
|
+
for await (const match of documentGlob(opts.registry).scan({ cwd: opts.root, dot: false })) {
|
|
165
|
+
if (skip(match)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const fp = fwd(resolve(opts.root, match));
|
|
169
|
+
try {
|
|
170
|
+
const { doc, serialize } = await loadDoc(fp, opts.registry);
|
|
171
|
+
const docNewDir = dirname(fp);
|
|
172
|
+
const docOldDir = isUnder(fp, absTo) ? dirname(absFrom + fp.slice(absTo.length)) : docNewDir;
|
|
173
|
+
|
|
174
|
+
const { changes } = rewriteDocRefs(doc, {
|
|
175
|
+
docNewDir,
|
|
176
|
+
docOldDir,
|
|
177
|
+
newAbs: absTo,
|
|
178
|
+
oldAbs: absFrom,
|
|
179
|
+
root,
|
|
180
|
+
});
|
|
181
|
+
const tagCount = tagRename ? rewriteTagName(doc, tagRename.from, tagRename.to).count : 0;
|
|
182
|
+
|
|
183
|
+
if (changes.length === 0 && tagCount === 0) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!serialize) {
|
|
187
|
+
report.errors.push({ error: `No serializer for "${match}"`, path: fwd(match) });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
await writeFile(fp, await serialize(doc));
|
|
191
|
+
|
|
192
|
+
if (changes.length > 0) {
|
|
193
|
+
report.references.files.push({ changes, count: changes.length, path: fwd(match) });
|
|
194
|
+
report.references.filesChanged += 1;
|
|
195
|
+
report.references.refsUpdated += changes.length;
|
|
196
|
+
}
|
|
197
|
+
if (tagCount > 0) {
|
|
198
|
+
tagFilesChanged += 1;
|
|
199
|
+
tagRefsUpdated += tagCount;
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
report.errors.push({ error: errorMessage(error), path: fwd(match) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (tagRename) {
|
|
207
|
+
report.tag = {
|
|
208
|
+
filesChanged: tagFilesChanged,
|
|
209
|
+
from: tagRename.from,
|
|
210
|
+
refsUpdated: tagRefsUpdated,
|
|
211
|
+
to: tagRename.to,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return report;
|
|
215
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fs-events.ts — pure translation + coalescing of chokidar events into the wire payloads the studio
|
|
3
|
+
* sidebar consumes. No filesystem access (node:path.relative is a pure string op), so it is shared
|
|
4
|
+
* by the dev-server watcher (watch.ts) and the desktop project session, and unit-tests directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { relative } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** A filesystem change as broadcast to the studio shell (root-relative, forward-slashed path). */
|
|
10
|
+
export interface FsEventPayload {
|
|
11
|
+
type: "add" | "change" | "unlink" | "addDir" | "unlinkDir";
|
|
12
|
+
path: string;
|
|
13
|
+
isDir: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const VALID = new Set(["add", "change", "unlink", "addDir", "unlinkDir"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map one chokidar event to a payload, or null for events we ignore (`ready`/`raw`/`error`) or
|
|
20
|
+
* paths that escape the root.
|
|
21
|
+
*/
|
|
22
|
+
export function toFsEvent(
|
|
23
|
+
eventType: string,
|
|
24
|
+
root: string,
|
|
25
|
+
changedPath: string,
|
|
26
|
+
): FsEventPayload | null {
|
|
27
|
+
if (!VALID.has(eventType)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const path = relative(root, changedPath).replaceAll("\\", "/");
|
|
31
|
+
if (!path || path.startsWith("..")) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
isDir: eventType === "addDir" || eventType === "unlinkDir",
|
|
36
|
+
path,
|
|
37
|
+
type: eventType as FsEventPayload["type"],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fold one event into a buffer, collapsing redundant churn per path (last-write-wins, except
|
|
43
|
+
* add+unlink cancels, add+change stays add, and unlink+add becomes a re-add). Returns a new array.
|
|
44
|
+
*/
|
|
45
|
+
export function coalesceFsEvents(buffer: FsEventPayload[], next: FsEventPayload): FsEventPayload[] {
|
|
46
|
+
const prev = buffer.find((e) => e.path === next.path);
|
|
47
|
+
if (!prev) {
|
|
48
|
+
return [...buffer, next];
|
|
49
|
+
}
|
|
50
|
+
const index = buffer.indexOf(prev);
|
|
51
|
+
const out = [...buffer];
|
|
52
|
+
// A file (or dir) created then removed within the window is a no-op.
|
|
53
|
+
if (
|
|
54
|
+
(prev.type === "add" && next.type === "unlink") ||
|
|
55
|
+
(prev.type === "addDir" && next.type === "unlinkDir")
|
|
56
|
+
) {
|
|
57
|
+
out.splice(index, 1);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
// A content change after a create is still just a create.
|
|
61
|
+
if ((prev.type === "add" || prev.type === "addDir") && next.type === "change") {
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
// Removed then recreated → treat as a fresh add.
|
|
65
|
+
if (prev.type === "unlink" && next.type === "add") {
|
|
66
|
+
out[index] = next;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
out[index] = next;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entrypoint for the rename-refactor engine (`@jxsuite/server/refactor`), shared by the
|
|
3
|
+
* dev-server endpoint and the desktop project session.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { applyRename, deriveTag } from "./apply.ts";
|
|
7
|
+
export type { ApplyRenameOptions, FileChange, RenameReport } from "./apply.ts";
|
|
8
|
+
export { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
9
|
+
export type { FsEventPayload } from "./fs-events.ts";
|
|
10
|
+
export { createFsWatcher } from "./watcher.ts";
|
|
11
|
+
export type { FsWatcherHandle } from "./watcher.ts";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths.ts — pure, OS-independent path math for the rename-refactor engine.
|
|
3
|
+
*
|
|
4
|
+
* Everything here operates on forward-slash POSIX strings and never touches the filesystem, so it
|
|
5
|
+
* unit-tests deterministically. Callers (apply.ts) normalise real paths with `fwd()` before handing
|
|
6
|
+
* them in. The relative-path style mirrors `computeRelativePath` in the studio
|
|
7
|
+
* (packages/studio/src/files/components.ts): "./" for same-dir/descendant targets, "../"-chains
|
|
8
|
+
* otherwise.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Classification of a reference string value. */
|
|
12
|
+
export type RefClass =
|
|
13
|
+
| { kind: "none" }
|
|
14
|
+
| { kind: "state" }
|
|
15
|
+
| { kind: "external" }
|
|
16
|
+
| { kind: "path"; core: string; suffix: string; rooted: boolean };
|
|
17
|
+
|
|
18
|
+
/** Split a trailing `?query` or `#fragment` off a path-like value. */
|
|
19
|
+
export function splitQueryHash(value: string): { core: string; suffix: string } {
|
|
20
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
21
|
+
const ch = value[i];
|
|
22
|
+
if (ch === "?" || ch === "#") {
|
|
23
|
+
return { core: value.slice(0, i), suffix: value.slice(i) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { core: value, suffix: "" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Classify a reference value. State-scope refs (`#/state/…`, `$map/…`, `parent#…`, `window#…`,
|
|
31
|
+
* `document#…`) and external URLs (`http:`, `data:`, protocol-relative `//…`) are never file paths.
|
|
32
|
+
* Everything else is treated as a `path`; the resolve-and-compare gate in `rewriteRef` provides the
|
|
33
|
+
* actual precision, so bare npm specifiers (which never resolve to the renamed file) are simply
|
|
34
|
+
* never matched.
|
|
35
|
+
*/
|
|
36
|
+
export function classifyRef(value: unknown): RefClass {
|
|
37
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
38
|
+
return { kind: "none" };
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
value === "$map" ||
|
|
42
|
+
value.startsWith("#") ||
|
|
43
|
+
value.startsWith("$map/") ||
|
|
44
|
+
value.startsWith("parent#") ||
|
|
45
|
+
value.startsWith("window#") ||
|
|
46
|
+
value.startsWith("document#")
|
|
47
|
+
) {
|
|
48
|
+
return { kind: "state" };
|
|
49
|
+
}
|
|
50
|
+
// Absolute URL with a scheme (http:, data:, mailto:) or protocol-relative (//host).
|
|
51
|
+
if (value.startsWith("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) {
|
|
52
|
+
return { kind: "external" };
|
|
53
|
+
}
|
|
54
|
+
const { core, suffix } = splitQueryHash(value);
|
|
55
|
+
if (core.length === 0) {
|
|
56
|
+
return { kind: "none" };
|
|
57
|
+
}
|
|
58
|
+
return { core, kind: "path", rooted: core.startsWith("/"), suffix };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Collapse `.`/`..` segments in a POSIX path. Absolute paths cannot escape above root. */
|
|
62
|
+
export function normalizeSegments(path: string): string {
|
|
63
|
+
const isAbs = path.startsWith("/");
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (const part of path.split("/")) {
|
|
66
|
+
if (part === "" || part === ".") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (part === "..") {
|
|
70
|
+
if (out.length > 0 && out.at(-1) !== "..") {
|
|
71
|
+
out.pop();
|
|
72
|
+
} else if (!isAbs) {
|
|
73
|
+
out.push("..");
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
out.push(part);
|
|
78
|
+
}
|
|
79
|
+
return (isAbs ? "/" : "") + out.join("/");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Join `rel` onto an absolute POSIX `base` and normalise (a leading "/" on rel still joins to
|
|
84
|
+
* base).
|
|
85
|
+
*/
|
|
86
|
+
export function joinPosix(base: string, rel: string): string {
|
|
87
|
+
return normalizeSegments(`${base}/${rel}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Relative path from directory `fromDir` to `to` (both absolute POSIX). Returns a bare path for
|
|
92
|
+
* descendants ("a/b.json") and "../"-chains otherwise — without a leading "./" (callers add it to
|
|
93
|
+
* preserve author style).
|
|
94
|
+
*/
|
|
95
|
+
export function relativeChain(fromDir: string, to: string): string {
|
|
96
|
+
const fromParts = fromDir.split("/").filter(Boolean);
|
|
97
|
+
const toParts = to.split("/").filter(Boolean);
|
|
98
|
+
let common = 0;
|
|
99
|
+
while (
|
|
100
|
+
common < fromParts.length &&
|
|
101
|
+
common < toParts.length &&
|
|
102
|
+
fromParts[common] === toParts[common]
|
|
103
|
+
) {
|
|
104
|
+
common += 1;
|
|
105
|
+
}
|
|
106
|
+
const ups = fromParts.length - common;
|
|
107
|
+
const remaining = toParts.slice(common);
|
|
108
|
+
return (ups > 0 ? "../".repeat(ups) : "") + remaining.join("/");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when `p` is `dir` itself or nested under it. */
|
|
112
|
+
export function isUnder(p: string, dir: string): boolean {
|
|
113
|
+
return p === dir || p.startsWith(`${dir}/`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Remap a resolved absolute target across a rename. If the target is the renamed file (or under the
|
|
118
|
+
* renamed directory), re-root it onto the new location; otherwise it is unaffected.
|
|
119
|
+
*/
|
|
120
|
+
export function remapTarget(targetOld: string, oldAbs: string, newAbs: string): string {
|
|
121
|
+
if (targetOld === oldAbs) {
|
|
122
|
+
return newAbs;
|
|
123
|
+
}
|
|
124
|
+
if (isUnder(targetOld, oldAbs)) {
|
|
125
|
+
return newAbs + targetOld.slice(oldAbs.length);
|
|
126
|
+
}
|
|
127
|
+
return targetOld;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Context describing a single rename, shared by every reference rewrite within one document. */
|
|
131
|
+
export interface RemapCtx {
|
|
132
|
+
/** Absolute POSIX project root. */
|
|
133
|
+
root: string;
|
|
134
|
+
/** Absolute POSIX path of the renamed file/dir, before the move. */
|
|
135
|
+
oldAbs: string;
|
|
136
|
+
/** Absolute POSIX path of the renamed file/dir, after the move. */
|
|
137
|
+
newAbs: string;
|
|
138
|
+
/** Directory the referencing document lived in before the move. */
|
|
139
|
+
docOldDir: string;
|
|
140
|
+
/**
|
|
141
|
+
* Directory the referencing document lives in after the move (differs from docOldDir only when
|
|
142
|
+
* the document itself is inside the moved subtree).
|
|
143
|
+
*/
|
|
144
|
+
docNewDir: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Recompute a single path reference for a rename, preserving its original style. Returns the new
|
|
149
|
+
* string, or null when nothing changes.
|
|
150
|
+
*
|
|
151
|
+
* `rootRelativeBare` makes a bare value (no "./" or "../") resolve against the project root rather
|
|
152
|
+
* than the document directory — used for `$layout`, which the compiler resolves against projectRoot
|
|
153
|
+
* (layout-resolver.ts), and for site-relative conventions.
|
|
154
|
+
*/
|
|
155
|
+
export function rewriteRef(
|
|
156
|
+
cls: { core: string; suffix: string; rooted: boolean },
|
|
157
|
+
ctx: RemapCtx,
|
|
158
|
+
rootRelativeBare = false,
|
|
159
|
+
): string | null {
|
|
160
|
+
const { core, rooted, suffix } = cls;
|
|
161
|
+
const startsRel = core.startsWith("./") || core.startsWith("../");
|
|
162
|
+
const useRoot = rooted || (rootRelativeBare && !startsRel);
|
|
163
|
+
const baseOld = useRoot ? ctx.root : ctx.docOldDir;
|
|
164
|
+
const baseNew = useRoot ? ctx.root : ctx.docNewDir;
|
|
165
|
+
|
|
166
|
+
const targetOld = joinPosix(useRoot ? ctx.root : baseOld, core);
|
|
167
|
+
const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);
|
|
168
|
+
if (targetNew === targetOld && baseOld === baseNew) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let out: string;
|
|
173
|
+
if (rooted) {
|
|
174
|
+
out = `/${relativeChain(ctx.root, targetNew)}`;
|
|
175
|
+
} else {
|
|
176
|
+
const rel = relativeChain(baseNew, targetNew);
|
|
177
|
+
if (rel.startsWith("..")) {
|
|
178
|
+
out = rel;
|
|
179
|
+
} else if (core.startsWith("./")) {
|
|
180
|
+
out = `./${rel}`;
|
|
181
|
+
} else {
|
|
182
|
+
out = rel;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
out += suffix;
|
|
186
|
+
return out === core + suffix ? null : out;
|
|
187
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refs.ts — pure document reference engine for rename-refactoring.
|
|
3
|
+
*
|
|
4
|
+
* Two passes, both operating on a parsed (JSON) document tree with no filesystem access: -
|
|
5
|
+
* `rewriteDocRefs` — recompute every file-path reference across a rename (Pillar B). -
|
|
6
|
+
* `rewriteTagName` — rename a custom-element tag wherever it is used (Pillar C).
|
|
7
|
+
*
|
|
8
|
+
* The walk is structure-aware (it dispatches by JSON key), not a blind string search, and mutates
|
|
9
|
+
* the passed document in place — callers parse a fresh object per file, so this is safe. Precision
|
|
10
|
+
* comes from `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten
|
|
11
|
+
* when it actually resolves to the renamed file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { classifyRef, rewriteRef } from "./paths.ts";
|
|
15
|
+
import type { RemapCtx } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
/** A single reference rewrite, for the rename report. */
|
|
18
|
+
export interface RefChange {
|
|
19
|
+
refType: string;
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RewriteResult {
|
|
25
|
+
doc: unknown;
|
|
26
|
+
changes: RefChange[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
|
|
30
|
+
const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
31
|
+
|
|
32
|
+
/** Recompute a scalar string reference under `key`, recording any change. */
|
|
33
|
+
function rewriteScalar(
|
|
34
|
+
container: Record<string, unknown>,
|
|
35
|
+
key: string,
|
|
36
|
+
refType: string,
|
|
37
|
+
ctx: RemapCtx,
|
|
38
|
+
changes: RefChange[],
|
|
39
|
+
rootRelativeBare: boolean,
|
|
40
|
+
): void {
|
|
41
|
+
const value = container[key];
|
|
42
|
+
const cls = classifyRef(value);
|
|
43
|
+
if (cls.kind !== "path") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const out = rewriteRef(cls, ctx, rootRelativeBare);
|
|
47
|
+
if (out !== null) {
|
|
48
|
+
changes.push({ from: value as string, refType, to: out });
|
|
49
|
+
container[key] = out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Recompute a string array member (e.g. an `$elements` entry), recording any change. */
|
|
54
|
+
function rewriteIndexed(
|
|
55
|
+
arr: unknown[],
|
|
56
|
+
index: number,
|
|
57
|
+
refType: string,
|
|
58
|
+
ctx: RemapCtx,
|
|
59
|
+
changes: RefChange[],
|
|
60
|
+
): void {
|
|
61
|
+
const value = arr[index];
|
|
62
|
+
const cls = classifyRef(value);
|
|
63
|
+
if (cls.kind !== "path") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const out = rewriteRef(cls, ctx, false);
|
|
67
|
+
if (out !== null) {
|
|
68
|
+
changes.push({ from: value as string, refType, to: out });
|
|
69
|
+
arr[index] = out;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Rewrite every `url(...)` target inside a CSS string. */
|
|
74
|
+
function rewriteUrls(value: string, ctx: RemapCtx, changes: RefChange[]): string {
|
|
75
|
+
return value.replaceAll(URL_RE, (match, quote: string, inner: string) => {
|
|
76
|
+
const cls = classifyRef(inner.trim());
|
|
77
|
+
if (cls.kind !== "path") {
|
|
78
|
+
return match;
|
|
79
|
+
}
|
|
80
|
+
const out = rewriteRef(cls, ctx, false);
|
|
81
|
+
if (out === null) {
|
|
82
|
+
return match;
|
|
83
|
+
}
|
|
84
|
+
changes.push({ from: inner.trim(), refType: "url", to: out });
|
|
85
|
+
return `url(${quote}${out}${quote})`;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Walk a `style` value tree, rewriting `url(...)` in every string it contains. */
|
|
90
|
+
function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
91
|
+
if (Array.isArray(node)) {
|
|
92
|
+
const arr = node as unknown[];
|
|
93
|
+
for (let i = 0; i < arr.length; i += 1) {
|
|
94
|
+
const item = arr[i];
|
|
95
|
+
if (typeof item === "string") {
|
|
96
|
+
const out = rewriteUrls(item, ctx, changes);
|
|
97
|
+
if (out !== item) {
|
|
98
|
+
arr[i] = out;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
walkStyle(item, ctx, changes);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!node || typeof node !== "object") {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const obj = node as Record<string, unknown>;
|
|
110
|
+
for (const key of Object.keys(obj)) {
|
|
111
|
+
const value = obj[key];
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
const out = rewriteUrls(value, ctx, changes);
|
|
114
|
+
if (out !== value) {
|
|
115
|
+
obj[key] = out;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
walkStyle(value, ctx, changes);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Recursive document walk dispatching each reference-bearing key. */
|
|
124
|
+
function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
125
|
+
if (Array.isArray(node)) {
|
|
126
|
+
for (const item of node as unknown[]) {
|
|
127
|
+
walk(item, ctx, changes);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!node || typeof node !== "object") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const obj = node as Record<string, unknown>;
|
|
135
|
+
for (const key of Object.keys(obj)) {
|
|
136
|
+
const value = obj[key];
|
|
137
|
+
switch (key) {
|
|
138
|
+
case "$ref": {
|
|
139
|
+
// File-path $ref only; state-scope refs (#/…, $map/…) are filtered by classifyRef.
|
|
140
|
+
rewriteScalar(obj, key, "$ref", ctx, changes, false);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "$layout": {
|
|
144
|
+
rewriteScalar(obj, key, "$layout", ctx, changes, true);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "$src":
|
|
148
|
+
case "$implementation": {
|
|
149
|
+
rewriteScalar(obj, key, key, ctx, changes, false);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "src":
|
|
153
|
+
case "href": {
|
|
154
|
+
rewriteScalar(obj, key, "attr", ctx, changes, false);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "imports": {
|
|
158
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
159
|
+
const imp = value as Record<string, unknown>;
|
|
160
|
+
for (const name of Object.keys(imp)) {
|
|
161
|
+
rewriteScalar(imp, name, "imports", ctx, changes, false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "$elements": {
|
|
167
|
+
if (Array.isArray(value)) {
|
|
168
|
+
const els = value as unknown[];
|
|
169
|
+
for (let i = 0; i < els.length; i += 1) {
|
|
170
|
+
if (typeof els[i] === "string") {
|
|
171
|
+
rewriteIndexed(els, i, "$elements", ctx, changes);
|
|
172
|
+
} else {
|
|
173
|
+
walk(els[i], ctx, changes);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
walk(value, ctx, changes);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "style": {
|
|
182
|
+
walkStyle(value, ctx, changes);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
default: {
|
|
186
|
+
walk(value, ctx, changes);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Rewrite every file-path reference in `doc` for a rename described by `ctx`. Mutates `doc`. */
|
|
193
|
+
export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
|
|
194
|
+
const changes: RefChange[] = [];
|
|
195
|
+
walk(doc, ctx, changes);
|
|
196
|
+
return { changes, doc };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Recursive walk setting `tagName: newTag` wherever it currently equals `oldTag`. */
|
|
200
|
+
function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: number }): void {
|
|
201
|
+
if (Array.isArray(node)) {
|
|
202
|
+
for (const item of node as unknown[]) {
|
|
203
|
+
walkTags(item, oldTag, newTag, counter);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!node || typeof node !== "object") {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const obj = node as Record<string, unknown>;
|
|
211
|
+
if (obj.tagName === oldTag) {
|
|
212
|
+
obj.tagName = newTag;
|
|
213
|
+
counter.n += 1;
|
|
214
|
+
}
|
|
215
|
+
for (const key of Object.keys(obj)) {
|
|
216
|
+
walkTags(obj[key], oldTag, newTag, counter);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Rename a custom-element tag throughout `doc` — instance nodes (`<old-tag>`), mapped-list
|
|
222
|
+
* `map.tagName`, and the component definition file's own root `tagName`. File references (`$ref`,
|
|
223
|
+
* `$elements`, `cases`) are intentionally left to `rewriteDocRefs`, so the two passes compose.
|
|
224
|
+
* Mutates `doc`.
|
|
225
|
+
*/
|
|
226
|
+
export function rewriteTagName(
|
|
227
|
+
doc: unknown,
|
|
228
|
+
oldTag: string,
|
|
229
|
+
newTag: string,
|
|
230
|
+
): { doc: unknown; count: number } {
|
|
231
|
+
const counter = { n: 0 };
|
|
232
|
+
if (oldTag !== newTag) {
|
|
233
|
+
walkTags(doc, oldTag, newTag, counter);
|
|
234
|
+
}
|
|
235
|
+
return { count: counter.n, doc };
|
|
236
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watcher.ts — a small chokidar wrapper that emits coalesced, batched FS-event payloads to a sink.
|
|
3
|
+
*
|
|
4
|
+
* Used by the desktop project session (which has no SSE stream) to push filesystem changes to its
|
|
5
|
+
* webview over RPC. The dev server keeps its own watcher in watch.ts (intertwined with
|
|
6
|
+
* build/reload); both share the pure `toFsEvent`/`coalesceFsEvents` helpers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { watch as chokidarWatch } from "chokidar";
|
|
10
|
+
import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
11
|
+
import type { FsEventPayload } from "./fs-events.ts";
|
|
12
|
+
|
|
13
|
+
const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
|
|
14
|
+
|
|
15
|
+
function isIgnored(path: string): boolean {
|
|
16
|
+
const f = path.replaceAll("\\", "/");
|
|
17
|
+
return IGNORE_SEGMENTS.some((s) => f.includes(`/${s}/`) || f.endsWith(`/${s}`));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FsWatcherHandle {
|
|
21
|
+
close: () => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Watch `root`, coalescing rapid changes and delivering batches to `sink`. Returns a handle whose
|
|
26
|
+
* `close()` stops the watcher and clears any pending batch.
|
|
27
|
+
*/
|
|
28
|
+
export function createFsWatcher(
|
|
29
|
+
root: string,
|
|
30
|
+
sink: (events: FsEventPayload[]) => void,
|
|
31
|
+
opts: { debounce?: number } = {},
|
|
32
|
+
): FsWatcherHandle {
|
|
33
|
+
const debounceMs = opts.debounce ?? 40;
|
|
34
|
+
let buffer: FsEventPayload[] = [];
|
|
35
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
|
|
37
|
+
const watcher = chokidarWatch(root, {
|
|
38
|
+
ignoreInitial: true,
|
|
39
|
+
ignorePermissionErrors: true,
|
|
40
|
+
ignored: (path) => isIgnored(path),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
watcher.on("all", (eventType, changedPath) => {
|
|
44
|
+
const event = toFsEvent(eventType, root, changedPath);
|
|
45
|
+
if (!event) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
buffer = coalesceFsEvents(buffer, event);
|
|
49
|
+
if (timer) {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
timer = setTimeout(() => {
|
|
53
|
+
if (buffer.length > 0) {
|
|
54
|
+
sink(buffer);
|
|
55
|
+
buffer = [];
|
|
56
|
+
}
|
|
57
|
+
}, debounceMs);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
close: async () => {
|
|
62
|
+
if (timer) {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
timer = null;
|
|
65
|
+
}
|
|
66
|
+
buffer = [];
|
|
67
|
+
await watcher.close();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -274,9 +274,12 @@ export async function createDevServer(options: {
|
|
|
274
274
|
// Static files
|
|
275
275
|
|
|
276
276
|
// If the URL path is an absolute filesystem path under the active project, serve directly.
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
277
|
+
// A POSIX absolute path arrives as "//abs/path"; a Windows one as "/C:/dir/file" (leading
|
|
278
|
+
// Slash + forward slashes), so drop the slash before the drive letter. Compare with
|
|
279
|
+
// Separators normalised, since activeProjectRoot is OS-native (backslashes on Windows).
|
|
280
|
+
const fsPath = path.startsWith("//") ? path.slice(1) : path.replace(/^\/([A-Za-z]:)/, "$1");
|
|
281
|
+
const normSep = (p: string) => p.replaceAll("\\", "/");
|
|
282
|
+
if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
|
|
280
283
|
const file = Bun.file(fsPath);
|
|
281
284
|
if (await file.exists()) {
|
|
282
285
|
return new Response(file);
|
package/src/studio-api.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* All paths are relative to the project root. Directory traversal above root is rejected.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
|
|
10
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
11
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
12
12
|
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
13
13
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { buildProjectFormatRegistry } from "@jxsuite/compiler/format-host";
|
|
15
|
+
import { applyRename } from "./refactor/apply.ts";
|
|
15
16
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
16
17
|
import * as claude from "./claude-session.ts";
|
|
17
18
|
import type { ClassJsonDef } from "./types.ts";
|
|
@@ -20,6 +21,14 @@ import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
|
20
21
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
21
22
|
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Expand a leading `~` to the user's home directory using path.join so separators are normalised (a
|
|
26
|
+
* plain string replace left the input's forward slashes intact, yielding mixed separators on
|
|
27
|
+
* Windows) and falling back to USERPROFILE where HOME is unset.
|
|
28
|
+
*/
|
|
29
|
+
const expandTilde = (p: string) =>
|
|
30
|
+
p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
|
|
31
|
+
|
|
23
32
|
interface PackageJson {
|
|
24
33
|
name?: string;
|
|
25
34
|
workspaces?: unknown;
|
|
@@ -189,8 +198,8 @@ export function parseGitStatus(out: string) {
|
|
|
189
198
|
} else if (line.startsWith("# branch.ab ")) {
|
|
190
199
|
const m = line.match(/\+(\d+) -(\d+)/);
|
|
191
200
|
if (m) {
|
|
192
|
-
ahead =
|
|
193
|
-
behind =
|
|
201
|
+
ahead = Math.trunc(Number(m[1]!));
|
|
202
|
+
behind = Math.trunc(Number(m[2]!));
|
|
194
203
|
}
|
|
195
204
|
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
196
205
|
const parts = line.split(" ");
|
|
@@ -317,17 +326,13 @@ export async function handleStudioApi(
|
|
|
317
326
|
}
|
|
318
327
|
try {
|
|
319
328
|
// Walk up from file's directory looking for project.json
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
);
|
|
329
|
+
const absFile = expandTilde(filePath);
|
|
330
|
+
let dir = dirname(absFile);
|
|
323
331
|
while (dir) {
|
|
324
332
|
const candidate = resolve(dir, "project.json");
|
|
325
333
|
if (existsSync(candidate)) {
|
|
326
334
|
const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
|
|
327
335
|
const relPath = fwd(dir);
|
|
328
|
-
const absFile = filePath.startsWith("~")
|
|
329
|
-
? filePath.replace("~", process.env.HOME || "")
|
|
330
|
-
: filePath;
|
|
331
336
|
const fileRelPath = fwd(relative(dir, absFile));
|
|
332
337
|
return Response.json({
|
|
333
338
|
fileRelPath,
|
|
@@ -804,7 +809,7 @@ export async function handleStudioApi(
|
|
|
804
809
|
if (!fp) {
|
|
805
810
|
return new Response("Missing path", { status: 400 });
|
|
806
811
|
}
|
|
807
|
-
const abs =
|
|
812
|
+
const abs = expandTilde(fp);
|
|
808
813
|
try {
|
|
809
814
|
assertAccessible(abs, root, activeProjectRoot);
|
|
810
815
|
} catch (error) {
|
|
@@ -913,13 +918,23 @@ export async function handleStudioApi(
|
|
|
913
918
|
try {
|
|
914
919
|
await mkdir(dirname(absTo), { recursive: true });
|
|
915
920
|
await rename(absFrom, absTo);
|
|
921
|
+
} catch (error) {
|
|
922
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
923
|
+
}
|
|
924
|
+
// Refactor pass: rewrite path references project-wide and, for a component, auto-rename its tag.
|
|
925
|
+
// The move already succeeded, so a refactor failure is reported but never fails the rename.
|
|
926
|
+
const scanRoot = activeProjectRoot ?? root;
|
|
927
|
+
try {
|
|
928
|
+
const registry = await getFormatRegistry(scanRoot);
|
|
929
|
+
const report = await applyRename({ absFrom, absTo, registry, root: scanRoot });
|
|
930
|
+
return Response.json(report);
|
|
931
|
+
} catch (error) {
|
|
916
932
|
return Response.json({
|
|
933
|
+
error: errorMessage(error),
|
|
917
934
|
from: fwd(relative(root, absFrom)),
|
|
918
935
|
ok: true,
|
|
919
936
|
to: fwd(relative(root, absTo)),
|
|
920
937
|
});
|
|
921
|
-
} catch (error) {
|
|
922
|
-
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
923
938
|
}
|
|
924
939
|
}
|
|
925
940
|
|
package/src/watch.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { watch as chokidarWatch } from "chokidar";
|
|
4
4
|
import { relative } from "node:path";
|
|
5
5
|
import { rebuild } from "./build.ts";
|
|
6
|
+
import { coalesceFsEvents, toFsEvent } from "./refactor/fs-events.ts";
|
|
6
7
|
import type { BuildEntry } from "./types.ts";
|
|
8
|
+
import type { FsEventPayload } from "./refactor/fs-events.ts";
|
|
7
9
|
|
|
8
10
|
const DEFAULT_IGNORE = [
|
|
9
11
|
"**/node_modules/**",
|
|
@@ -86,6 +88,17 @@ export function createWatcher(
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Send a _named_ SSE event. The preview iframe only listens to the default (unnamed) `onmessage`,
|
|
93
|
+
* so named events (e.g. "fs") reach the studio shell without triggering a preview reload.
|
|
94
|
+
*/
|
|
95
|
+
function broadcastEvent(event: string, payload: unknown) {
|
|
96
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
97
|
+
for (const send of clients) {
|
|
98
|
+
send(frame);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
function handleSSE() {
|
|
90
103
|
/** @type {((msg: string) => void) | undefined} */
|
|
91
104
|
let send: ((msg: string) => void) | undefined;
|
|
@@ -121,6 +134,9 @@ export function createWatcher(
|
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
137
|
+
let fsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
138
|
+
let fsBuffer: FsEventPayload[] = [];
|
|
139
|
+
const fsDebounceMs = 40;
|
|
124
140
|
const watcher = chokidarWatch(root, {
|
|
125
141
|
awaitWriteFinish: {
|
|
126
142
|
pollInterval: 10,
|
|
@@ -131,11 +147,26 @@ export function createWatcher(
|
|
|
131
147
|
ignored: (watchedPath) => shouldIgnore(watchedPath, ignore),
|
|
132
148
|
});
|
|
133
149
|
|
|
134
|
-
watcher.on("all", (
|
|
150
|
+
watcher.on("all", (eventType, changedPath) => {
|
|
135
151
|
const filename = relative(root, changedPath);
|
|
136
152
|
if (!filename || filename.startsWith("..")) {
|
|
137
153
|
return;
|
|
138
154
|
}
|
|
155
|
+
|
|
156
|
+
// Structured FS events for the studio sidebar (coalesced + batched), emitted as a named "fs"
|
|
157
|
+
// SSE event so the preview iframe ignores them while the studio shell subscribes to them.
|
|
158
|
+
const fsEvent = toFsEvent(eventType, root, changedPath);
|
|
159
|
+
if (fsEvent) {
|
|
160
|
+
fsBuffer = coalesceFsEvents(fsBuffer, fsEvent);
|
|
161
|
+
clearTimeout(fsTimer ?? undefined);
|
|
162
|
+
fsTimer = setTimeout(() => {
|
|
163
|
+
if (fsBuffer.length > 0) {
|
|
164
|
+
broadcastEvent("fs", { events: fsBuffer });
|
|
165
|
+
fsBuffer = [];
|
|
166
|
+
}
|
|
167
|
+
}, fsDebounceMs);
|
|
168
|
+
}
|
|
169
|
+
|
|
139
170
|
clearTimeout(timer ?? undefined);
|
|
140
171
|
timer = setTimeout(async () => {
|
|
141
172
|
if (builds.length > 0) {
|
|
@@ -155,5 +186,5 @@ export function createWatcher(
|
|
|
155
186
|
}, debounceMs);
|
|
156
187
|
});
|
|
157
188
|
|
|
158
|
-
return { broadcast, handleSSE, watcher };
|
|
189
|
+
return { broadcast, broadcastEvent, handleSSE, watcher };
|
|
159
190
|
}
|