castle-web-cli 0.4.121 → 0.4.123
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/agent-prompts.js +6 -2
- package/dist/diffText.d.ts +14 -0
- package/dist/diffText.js +138 -0
- package/dist/editorConfig.d.ts +6 -1
- package/dist/editorConfig.js +20 -1
- package/dist/ide.d.ts +2 -1
- package/dist/ide.js +91 -2
- package/dist/index.js +48 -2
- package/dist/init.js +30 -0
- package/dist/localPaths.d.ts +1 -0
- package/dist/localPaths.js +5 -0
- package/dist/shell/assets/index-CARLHafh.css +1 -0
- package/dist/shell/assets/{index-UEwjXWKI.js → index-DWgt4KzC.js} +63 -63
- package/dist/shell/index.html +2 -2
- package/dist/versionStore.d.ts +54 -0
- package/dist/versionStore.js +281 -0
- package/dist/versions.d.ts +82 -0
- package/dist/versions.js +446 -0
- package/dist/vitePlugins.js +21 -8
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SingleEditor.jsx +43 -2
- package/kits/physics-2d/editors/editorRegistry.jsx +34 -0
- package/kits/physics-2d/main.jsx +11 -2
- package/kits/physics-2d/package-lock.json +1 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BkJ87APM.css +0 -1
package/dist/versions.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// save-version / list-versions / restore-version.
|
|
2
|
+
//
|
|
3
|
+
// A version is a manual commit of the deck's files. Storage details are in
|
|
4
|
+
// versionStore.ts; this file is the behavior:
|
|
5
|
+
//
|
|
6
|
+
// save-version hashes the whole tree and records what moved
|
|
7
|
+
// save-version <paths...> records ONLY those paths -- a curated state, not
|
|
8
|
+
// the working tree at that moment
|
|
9
|
+
// list-versions [<id>] walks back from head (or from the given version)
|
|
10
|
+
// restore-version <id> puts the working tree back to that version,
|
|
11
|
+
// touching only paths some later version touched
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { customAlphabet } from 'nanoid';
|
|
15
|
+
import { looksBinary, unifiedDiff } from './diffText.js';
|
|
16
|
+
import { COVER_FILE } from './localPaths.js';
|
|
17
|
+
import * as store from './versionStore.js';
|
|
18
|
+
// Version ids are alphanumeric on purpose: they are filenames, command-line
|
|
19
|
+
// positionals and (later) url segments, and nanoid's default alphabet includes
|
|
20
|
+
// `-`, which reads as a flag wherever an id is typed.
|
|
21
|
+
const newVersionId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 12);
|
|
22
|
+
function resolveProjectDir(dir) {
|
|
23
|
+
const projectDir = path.resolve(dir);
|
|
24
|
+
if (!fs.existsSync(projectDir))
|
|
25
|
+
throw new Error(`No such directory: ${projectDir}`);
|
|
26
|
+
return projectDir;
|
|
27
|
+
}
|
|
28
|
+
// A path on the command line may name a directory, which stands for every
|
|
29
|
+
// tracked file under it -- on disk AND in the parent manifest, so deleting a
|
|
30
|
+
// whole directory and saving it still records the deletions.
|
|
31
|
+
function expandSelection(projectDir, given, parentFiles) {
|
|
32
|
+
const workingFiles = store.walkDeckFiles(projectDir);
|
|
33
|
+
const selected = new Set();
|
|
34
|
+
for (const raw of given) {
|
|
35
|
+
const rel = store.toDeckRelative(projectDir, raw);
|
|
36
|
+
if (!rel)
|
|
37
|
+
throw new Error(`Path is outside the deck: ${raw}`);
|
|
38
|
+
if (store.isIgnoredPath(rel))
|
|
39
|
+
throw new Error(`Path is not tracked: ${rel}`);
|
|
40
|
+
const isDir = fs.existsSync(path.join(projectDir, rel)) && fs.statSync(path.join(projectDir, rel)).isDirectory();
|
|
41
|
+
if (isDir) {
|
|
42
|
+
const prefix = `${rel}/`;
|
|
43
|
+
for (const p of workingFiles)
|
|
44
|
+
if (p.startsWith(prefix))
|
|
45
|
+
selected.add(p);
|
|
46
|
+
for (const p of Object.keys(parentFiles))
|
|
47
|
+
if (p.startsWith(prefix))
|
|
48
|
+
selected.add(p);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!fs.existsSync(path.join(projectDir, rel)) && !(rel in parentFiles)) {
|
|
52
|
+
throw new Error(`No such file: ${rel}`);
|
|
53
|
+
}
|
|
54
|
+
selected.add(rel);
|
|
55
|
+
}
|
|
56
|
+
return [...selected].sort();
|
|
57
|
+
}
|
|
58
|
+
// The whole tree becomes the manifest. Content is stored as we go, so an
|
|
59
|
+
// unchanged file costs a read and a hash and writes nothing new.
|
|
60
|
+
function manifestFromTree(projectDir) {
|
|
61
|
+
const files = {};
|
|
62
|
+
for (const rel of store.walkDeckFiles(projectDir)) {
|
|
63
|
+
const hash = store.storeWorkingFile(projectDir, rel);
|
|
64
|
+
if (hash)
|
|
65
|
+
files[rel] = hash;
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
// The parent's manifest with only the selected paths overridden. Everything
|
|
70
|
+
// else keeps the parent's hash -- so the version describes a curated state, and
|
|
71
|
+
// the paths left out stay dirty on disk for the next save to pick up.
|
|
72
|
+
function manifestFromSelection(projectDir, parentFiles, selected) {
|
|
73
|
+
const files = { ...parentFiles };
|
|
74
|
+
for (const rel of selected) {
|
|
75
|
+
const hash = store.storeWorkingFile(projectDir, rel);
|
|
76
|
+
if (hash)
|
|
77
|
+
files[rel] = hash;
|
|
78
|
+
else
|
|
79
|
+
delete files[rel];
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
// The save itself, with no output -- what the CLI command and the editor's
|
|
84
|
+
// Save version button both run.
|
|
85
|
+
export function createVersion(projectDir, opts = {}) {
|
|
86
|
+
const parentId = store.readHead(projectDir);
|
|
87
|
+
const parentFiles = store.parentManifest(projectDir, parentId);
|
|
88
|
+
const given = opts.paths ?? [];
|
|
89
|
+
const selected = given.length > 0 ? expandSelection(projectDir, given, parentFiles) : null;
|
|
90
|
+
const files = selected
|
|
91
|
+
? manifestFromSelection(projectDir, parentFiles, selected)
|
|
92
|
+
: manifestFromTree(projectDir);
|
|
93
|
+
const changes = store.diffManifests(parentFiles, files, selected ?? undefined);
|
|
94
|
+
if (Object.keys(changes).length === 0)
|
|
95
|
+
return { version: null, partial: selected !== null };
|
|
96
|
+
const version = {
|
|
97
|
+
id: newVersionId(),
|
|
98
|
+
parentId,
|
|
99
|
+
createdAt: new Date().toISOString(),
|
|
100
|
+
message: opts.message ?? '',
|
|
101
|
+
files,
|
|
102
|
+
changes,
|
|
103
|
+
};
|
|
104
|
+
store.writeVersion(projectDir, version);
|
|
105
|
+
store.writeHead(projectDir, version.id);
|
|
106
|
+
return { version, partial: selected !== null };
|
|
107
|
+
}
|
|
108
|
+
export function saveVersion(dir, opts = {}) {
|
|
109
|
+
const projectDir = resolveProjectDir(dir);
|
|
110
|
+
const { version, partial } = createVersion(projectDir, opts);
|
|
111
|
+
if (!version) {
|
|
112
|
+
console.log(partial ? 'No changes in the selected paths. Nothing saved.' : 'No changes. Nothing saved.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const changes = version.changes;
|
|
116
|
+
const count = Object.keys(changes).length;
|
|
117
|
+
console.log(`Saved version ${version.id} (${count} file${count === 1 ? '' : 's'} changed).`);
|
|
118
|
+
for (const [p, change] of Object.entries(changes)) {
|
|
119
|
+
const mark = change.before === null ? 'added' : change.after === null ? 'deleted' : 'changed';
|
|
120
|
+
console.log(` ${mark} ${p}`);
|
|
121
|
+
}
|
|
122
|
+
if (partial) {
|
|
123
|
+
console.log('Partial save: unselected files are still dirty on disk.');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// The deck cover is rewritten every time the deck is played, so it is
|
|
127
|
+
// permanently dirty. It is versioned and restored like any other file, but it
|
|
128
|
+
// never counts as a change worth reporting and never blocks a restore -- the
|
|
129
|
+
// old cover it writes back regenerates on the next play. Without this, every
|
|
130
|
+
// restore refused on a file nobody edited.
|
|
131
|
+
function isReportableChange(path) {
|
|
132
|
+
return path !== COVER_FILE;
|
|
133
|
+
}
|
|
134
|
+
function changeKind(change) {
|
|
135
|
+
if (change.before === null)
|
|
136
|
+
return 'added';
|
|
137
|
+
if (change.after === null)
|
|
138
|
+
return 'deleted';
|
|
139
|
+
return 'changed';
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* What has changed on disk since head, in the same shape a version's `changes`
|
|
143
|
+
* takes. Hashes the tree WITHOUT storing anything -- asking what is outstanding
|
|
144
|
+
* must not write to the store.
|
|
145
|
+
*/
|
|
146
|
+
export function pendingChanges(projectDir) {
|
|
147
|
+
const headFiles = store.parentManifest(projectDir, store.readHead(projectDir));
|
|
148
|
+
const current = {};
|
|
149
|
+
for (const rel of store.walkDeckFiles(projectDir)) {
|
|
150
|
+
const hash = store.hashWorkingFile(projectDir, rel);
|
|
151
|
+
if (hash)
|
|
152
|
+
current[rel] = hash;
|
|
153
|
+
}
|
|
154
|
+
return Object.entries(store.diffManifests(headFiles, current))
|
|
155
|
+
.filter(([path]) => isReportableChange(path))
|
|
156
|
+
.map(([path, change]) => ({ path, kind: changeKind(change) }));
|
|
157
|
+
}
|
|
158
|
+
function toSummary(version, later) {
|
|
159
|
+
return {
|
|
160
|
+
id: version.id,
|
|
161
|
+
parentId: version.parentId,
|
|
162
|
+
createdAt: version.createdAt,
|
|
163
|
+
message: version.message,
|
|
164
|
+
changes: Object.entries(version.changes)
|
|
165
|
+
.filter(([path]) => isReportableChange(path))
|
|
166
|
+
.map(([path, change]) => ({ path, kind: changeKind(change) })),
|
|
167
|
+
later,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The deck's history newest first. From head (no `fromId`): the chain forward
|
|
172
|
+
* from head -- marked `later` -- then head, then its ancestors. From a named
|
|
173
|
+
* version: that version and its ancestors, as it has always been.
|
|
174
|
+
*
|
|
175
|
+
* The forward walk is why a restore no longer makes the versions after it
|
|
176
|
+
* vanish from the panel and from `list-versions`. It follows one chain of
|
|
177
|
+
* children, newest child at each step. After a save there is nothing to follow,
|
|
178
|
+
* because the new version is a leaf, so the forward rows drop away on their own
|
|
179
|
+
* -- the abandoned versions are still on disk, just no longer reachable forward
|
|
180
|
+
* from head.
|
|
181
|
+
*
|
|
182
|
+
* Naming a version asks a different question -- how did THIS version come to be
|
|
183
|
+
* -- so that form stays a walk back and is unchanged.
|
|
184
|
+
*/
|
|
185
|
+
export function versionSummaries(projectDir, fromId) {
|
|
186
|
+
const startId = fromId ?? store.readHead(projectDir);
|
|
187
|
+
if (!startId)
|
|
188
|
+
return [];
|
|
189
|
+
if (!store.versionExists(projectDir, startId))
|
|
190
|
+
throw new Error(`No such version: ${startId}`);
|
|
191
|
+
const forward = fromId ? [] : store.forwardChain(projectDir, startId);
|
|
192
|
+
return [
|
|
193
|
+
// Nearest child first out of the walk, so reverse it: the furthest
|
|
194
|
+
// descendant is the newest thing there is.
|
|
195
|
+
...[...forward].reverse().map((v) => toSummary(v, true)),
|
|
196
|
+
...store.versionChain(projectDir, startId).map((v) => toSummary(v, false)),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
export function listVersions(dir, version) {
|
|
200
|
+
const summaries = versionSummaries(resolveProjectDir(dir), version);
|
|
201
|
+
if (summaries.length === 0) {
|
|
202
|
+
console.log('No versions yet.');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const v of summaries) {
|
|
206
|
+
const count = v.changes.length;
|
|
207
|
+
const message = v.message ? ` ${v.message}` : '';
|
|
208
|
+
// The same history the panel shows, marked the same way, so the two never
|
|
209
|
+
// disagree about what happened.
|
|
210
|
+
const later = v.later ? ' (later)' : '';
|
|
211
|
+
console.log(`${v.id} ${v.createdAt} ${count} file${count === 1 ? '' : 's'}${later}${message}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// One file's side of a change, as text -- or null when it wasn't there (an add
|
|
215
|
+
// or a delete) and `binary: true` when it must not be rendered as a patch.
|
|
216
|
+
function blobText(projectDir, hash) {
|
|
217
|
+
if (hash === null)
|
|
218
|
+
return null;
|
|
219
|
+
const content = store.getBlob(projectDir, hash);
|
|
220
|
+
return looksBinary(content) ? { text: '', binary: true } : { text: content.toString('utf8'), binary: false };
|
|
221
|
+
}
|
|
222
|
+
function showFileChange(projectDir, filePath, change) {
|
|
223
|
+
const kind = changeKind(change);
|
|
224
|
+
const before = blobText(projectDir, change.before);
|
|
225
|
+
const after = blobText(projectDir, change.after);
|
|
226
|
+
// A sprite or an image must never come out as a garbled patch; a line saying
|
|
227
|
+
// what happened to it is the whole useful answer.
|
|
228
|
+
if (before?.binary || after?.binary) {
|
|
229
|
+
console.log(`${filePath} ${kind}, binary`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
console.log(`${filePath} ${kind}`);
|
|
233
|
+
const hunks = unifiedDiff(before?.text ?? '', after?.text ?? '');
|
|
234
|
+
if (!hunks) {
|
|
235
|
+
console.log(' (too large to show as a diff)');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
for (const line of hunks)
|
|
239
|
+
console.log(line);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* What one version changed, as diffs of the CHANGED FILES ONLY -- built from the
|
|
243
|
+
* two blobs each entry in `changes` already names, so nothing new is stored and
|
|
244
|
+
* the whole tree is never read. The CLI counterpart of the panel's row
|
|
245
|
+
* expansion, for an agent working out what a change did.
|
|
246
|
+
*/
|
|
247
|
+
export function showVersion(dir, versionId) {
|
|
248
|
+
const projectDir = resolveProjectDir(dir);
|
|
249
|
+
if (!store.versionExists(projectDir, versionId))
|
|
250
|
+
throw new Error(`No such version: ${versionId}`);
|
|
251
|
+
const version = store.readVersion(projectDir, versionId);
|
|
252
|
+
const paths = Object.keys(version.changes).sort();
|
|
253
|
+
console.log(`${version.id} ${version.createdAt} ${paths.length} file${paths.length === 1 ? '' : 's'}`);
|
|
254
|
+
if (version.message)
|
|
255
|
+
console.log(version.message);
|
|
256
|
+
console.log('');
|
|
257
|
+
for (const filePath of paths) {
|
|
258
|
+
showFileChange(projectDir, filePath, version.changes[filePath]);
|
|
259
|
+
console.log('');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The versions from `from` back to `to`, exclusive of `to`, newest first -- or
|
|
264
|
+
* null when `to` is not an ancestor of `from`. Only `parentId` is stored, so
|
|
265
|
+
* this is the only direction the history can be walked directly.
|
|
266
|
+
*/
|
|
267
|
+
function chainDownTo(projectDir, from, to) {
|
|
268
|
+
const chain = [];
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
let cursor = from;
|
|
271
|
+
while (cursor && cursor !== to) {
|
|
272
|
+
if (seen.has(cursor))
|
|
273
|
+
throw new Error(`Version history loops at ${cursor}`);
|
|
274
|
+
seen.add(cursor);
|
|
275
|
+
const version = store.readVersion(projectDir, cursor);
|
|
276
|
+
chain.push(version);
|
|
277
|
+
cursor = version.parentId;
|
|
278
|
+
}
|
|
279
|
+
return cursor === to ? chain : null;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* What a restore should write, and what it expects to find on disk first.
|
|
283
|
+
*
|
|
284
|
+
* The two directions are mirrors of each other over the SAME set of versions --
|
|
285
|
+
* the ones between head and the target -- read from opposite ends:
|
|
286
|
+
*
|
|
287
|
+
* backward (the target is an ancestor of head): put back the EARLIEST
|
|
288
|
+
* `before`, the state closest to the target; expect the LATEST `after`, the
|
|
289
|
+
* state closest to head.
|
|
290
|
+
* forward (the target is a descendant of head): put back the LATEST `after`,
|
|
291
|
+
* again the state closest to the target; expect the EARLIEST `before`,
|
|
292
|
+
* again the state closest to head.
|
|
293
|
+
*
|
|
294
|
+
* Either way a path no version between the two touched never enters the plan,
|
|
295
|
+
* so unsaved work on it survives.
|
|
296
|
+
*/
|
|
297
|
+
function planRestore(projectDir, targetId) {
|
|
298
|
+
const head = store.readHead(projectDir);
|
|
299
|
+
if (!head)
|
|
300
|
+
throw new Error('No versions yet.');
|
|
301
|
+
const backward = chainDownTo(projectDir, head, targetId);
|
|
302
|
+
if (backward)
|
|
303
|
+
return planFromChain(backward, 'backward');
|
|
304
|
+
const forward = chainDownTo(projectDir, targetId, head);
|
|
305
|
+
if (forward)
|
|
306
|
+
return planFromChain(forward, 'forward');
|
|
307
|
+
throw new NotOnThisLine(targetId, head);
|
|
308
|
+
}
|
|
309
|
+
// `chain` is always newest first, so "first seen wins" reads the end nearest the
|
|
310
|
+
// newer version and "last write wins" reads the end nearest the older one.
|
|
311
|
+
function planFromChain(chain, direction) {
|
|
312
|
+
const plan = {
|
|
313
|
+
wanted: {},
|
|
314
|
+
expected: {},
|
|
315
|
+
offChain: direction === 'backward' ? chain.length : 0,
|
|
316
|
+
};
|
|
317
|
+
for (const version of chain) {
|
|
318
|
+
for (const [p, change] of Object.entries(version.changes)) {
|
|
319
|
+
if (direction === 'backward') {
|
|
320
|
+
plan.wanted[p] = change.before;
|
|
321
|
+
if (!(p in plan.expected))
|
|
322
|
+
plan.expected[p] = change.after;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
if (!(p in plan.wanted))
|
|
326
|
+
plan.wanted[p] = change.after;
|
|
327
|
+
plan.expected[p] = change.before;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return plan;
|
|
332
|
+
}
|
|
333
|
+
function removeEmptyParents(projectDir, relPath) {
|
|
334
|
+
let dir = path.dirname(path.join(projectDir, relPath));
|
|
335
|
+
while (dir.startsWith(projectDir) && dir !== projectDir) {
|
|
336
|
+
try {
|
|
337
|
+
if (fs.readdirSync(dir).length > 0)
|
|
338
|
+
return;
|
|
339
|
+
fs.rmdirSync(dir);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
dir = path.dirname(dir);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function applyRestore(projectDir, actions) {
|
|
348
|
+
for (const [rel, hash] of actions) {
|
|
349
|
+
const abs = path.join(projectDir, rel);
|
|
350
|
+
if (hash === null) {
|
|
351
|
+
fs.rmSync(abs, { force: true });
|
|
352
|
+
removeEmptyParents(projectDir, rel);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
356
|
+
fs.writeFileSync(abs, store.getBlob(projectDir, hash));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Work no version has recorded, which a restore would overwrite. The paths ARE
|
|
360
|
+
// the answer to "what would I lose", so they ride on the error and each caller
|
|
361
|
+
// says what to do about it -- the CLI offers `--force`, the panel has no such
|
|
362
|
+
// thing and says to save a version first.
|
|
363
|
+
export class UnsavedChanges extends Error {
|
|
364
|
+
paths;
|
|
365
|
+
constructor(paths) {
|
|
366
|
+
const count = paths.length;
|
|
367
|
+
super(`Unsaved changes would be lost in ${count} file${count === 1 ? '' : 's'}:\n` +
|
|
368
|
+
paths.map((p) => ` ${p}`).join('\n'));
|
|
369
|
+
this.name = 'UnsavedChanges';
|
|
370
|
+
this.paths = paths;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// A version on a different branch: neither an ancestor of head nor a descendant
|
|
374
|
+
// of it. Restore moves along one line in either direction; getting from here to
|
|
375
|
+
// there would mean merging, which is not something this build does silently.
|
|
376
|
+
export class NotOnThisLine extends Error {
|
|
377
|
+
constructor(targetId, head) {
|
|
378
|
+
super(`Version ${targetId} is on a different branch than ${head}. ` +
|
|
379
|
+
'Restore moves back or forward along one line, not across a fork.');
|
|
380
|
+
this.name = 'NotOnThisLine';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// The restore itself, with no output. Throws on unsaved changes unless forced --
|
|
384
|
+
// the message names every conflicting path, since that list IS the answer to
|
|
385
|
+
// "what would I lose".
|
|
386
|
+
export function applyVersionRestore(projectDir, targetId, opts = {}) {
|
|
387
|
+
if (!store.versionExists(projectDir, targetId))
|
|
388
|
+
throw new Error(`No such version: ${targetId}`);
|
|
389
|
+
if (store.readHead(projectDir) === targetId) {
|
|
390
|
+
return { alreadyThere: true, applied: [], offChain: 0 };
|
|
391
|
+
}
|
|
392
|
+
const plan = planRestore(projectDir, targetId);
|
|
393
|
+
// Only paths whose content would actually move are checked and applied: a
|
|
394
|
+
// path already holding what the restore wants is neither a conflict nor work.
|
|
395
|
+
const actions = [];
|
|
396
|
+
const conflicts = [];
|
|
397
|
+
for (const [rel, wanted] of Object.entries(plan.wanted)) {
|
|
398
|
+
const current = store.hashWorkingFile(projectDir, rel);
|
|
399
|
+
if (current === wanted)
|
|
400
|
+
continue;
|
|
401
|
+
// The cover is exempt: it is dirty by construction, so refusing on it would
|
|
402
|
+
// refuse every restore. It still gets written back.
|
|
403
|
+
if (current !== plan.expected[rel] && isReportableChange(rel))
|
|
404
|
+
conflicts.push(rel);
|
|
405
|
+
actions.push([rel, wanted]);
|
|
406
|
+
}
|
|
407
|
+
if (conflicts.length > 0 && !opts.force)
|
|
408
|
+
throw new UnsavedChanges(conflicts.sort());
|
|
409
|
+
applyRestore(projectDir, actions.sort((a, b) => a[0].localeCompare(b[0])));
|
|
410
|
+
// Head moves to the target, so the next save continues from there and the
|
|
411
|
+
// versions we walked past fork off. They stay on disk and stay loadable by
|
|
412
|
+
// id -- they are just no longer on head's chain.
|
|
413
|
+
store.writeHead(projectDir, targetId);
|
|
414
|
+
return { alreadyThere: false, applied: actions, offChain: plan.offChain };
|
|
415
|
+
}
|
|
416
|
+
export function restoreVersion(dir, targetId, opts = {}) {
|
|
417
|
+
const projectDir = resolveProjectDir(dir);
|
|
418
|
+
let outcome;
|
|
419
|
+
try {
|
|
420
|
+
outcome = applyVersionRestore(projectDir, targetId, opts);
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
// Only the command line has a --force, so only the command line mentions it.
|
|
424
|
+
if (e instanceof UnsavedChanges) {
|
|
425
|
+
throw new Error(`${e.message}\nSave a version first, or pass --force to restore anyway.`);
|
|
426
|
+
}
|
|
427
|
+
throw e;
|
|
428
|
+
}
|
|
429
|
+
if (outcome.alreadyThere) {
|
|
430
|
+
console.log(`Already at version ${targetId}. Nothing to restore.`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const applied = outcome.applied;
|
|
434
|
+
if (applied.length === 0) {
|
|
435
|
+
console.log(`The working tree already matched version ${targetId}.`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
console.log(`Restored ${applied.length} file${applied.length === 1 ? '' : 's'} to version ${targetId}.`);
|
|
439
|
+
for (const [rel, hash] of applied)
|
|
440
|
+
console.log(` ${hash === null ? 'deleted' : 'restored'} ${rel}`);
|
|
441
|
+
}
|
|
442
|
+
// A silent head move is the thing people discover a week later. What the move
|
|
443
|
+
// left behind needs no announcement: `list-versions` shows those versions,
|
|
444
|
+
// marked `(later)`.
|
|
445
|
+
console.log(`head is now ${targetId}.`);
|
|
446
|
+
}
|
package/dist/vitePlugins.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import { IMPORTS_DIR } from './imports.js';
|
|
4
|
-
import { resolveFileTypes } from './editorConfig.js';
|
|
4
|
+
import { dataModeOf, resolveFileTypes } from './editorConfig.js';
|
|
5
5
|
// Mirrors castle-web-sdk's IMPORTS_PREFIX, which is the definition deck code
|
|
6
6
|
// uses. Duplicated rather than imported: the CLI compiles under node16 module
|
|
7
7
|
// resolution and the SDK's published types don't, and one short string is not
|
|
@@ -25,13 +25,22 @@ const IMPORTS_PREFIX = '@imports/';
|
|
|
25
25
|
// declares anything, kept so decks written before the declaration existed behave
|
|
26
26
|
// as they did.
|
|
27
27
|
const FALLBACK_DATA_FILE_EXTS = ['.scene', '.drawing', '.pxart', '.sprite'];
|
|
28
|
-
|
|
28
|
+
// Extension -> how to hand it back when a module imports it. "json" parses it,
|
|
29
|
+
// "text" returns the raw string; a format that is neither (a `.md` an editor
|
|
30
|
+
// owns, say) simply isn't in here and stays an ordinary module.
|
|
31
|
+
function dataFileModes(root) {
|
|
29
32
|
const resolved = resolveFileTypes(root);
|
|
30
33
|
// Declared file types with no data entries mean exactly that: a deck whose
|
|
31
34
|
// files are all modules. Only "nobody said anything" falls back.
|
|
32
35
|
if (!resolved)
|
|
33
|
-
return FALLBACK_DATA_FILE_EXTS;
|
|
34
|
-
|
|
36
|
+
return new Map(FALLBACK_DATA_FILE_EXTS.map((ext) => [ext, 'json']));
|
|
37
|
+
const modes = new Map();
|
|
38
|
+
for (const type of resolved) {
|
|
39
|
+
const mode = dataModeOf(type);
|
|
40
|
+
if (mode)
|
|
41
|
+
modes.set(type.ext, mode);
|
|
42
|
+
}
|
|
43
|
+
return modes;
|
|
35
44
|
}
|
|
36
45
|
// `@imports/<alias>/...` is how a deck names a file of another deck it imports,
|
|
37
46
|
// in JS as in everything else (see castle-web-sdk's resolveDeckFile). Imports are
|
|
@@ -62,19 +71,23 @@ export function importsAliasPlugin() {
|
|
|
62
71
|
export function sceneFilesPlugin() {
|
|
63
72
|
// Resolved once per plugin instance, from the root vite is configured with --
|
|
64
73
|
// the deck being served or bundled.
|
|
65
|
-
let
|
|
74
|
+
let modes = new Map(FALLBACK_DATA_FILE_EXTS.map((ext) => [ext, 'json']));
|
|
66
75
|
return {
|
|
67
76
|
name: 'castle-scene-files',
|
|
68
77
|
configResolved(config) {
|
|
69
|
-
|
|
78
|
+
modes = dataFileModes(config.root);
|
|
70
79
|
},
|
|
71
80
|
load(id) {
|
|
72
81
|
const [filePath, query] = id.split('?');
|
|
73
82
|
if (query !== undefined)
|
|
74
83
|
return null;
|
|
75
|
-
|
|
84
|
+
for (const [ext, mode] of modes) {
|
|
85
|
+
if (!filePath.endsWith(ext))
|
|
86
|
+
continue;
|
|
76
87
|
const text = fs.readFileSync(filePath, 'utf-8');
|
|
77
|
-
return
|
|
88
|
+
return mode === 'text'
|
|
89
|
+
? `export default ${JSON.stringify(text)};\n`
|
|
90
|
+
: `export default JSON.parse(${JSON.stringify(text)});\n`;
|
|
78
91
|
}
|
|
79
92
|
return null;
|
|
80
93
|
},
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import React, { useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
|
|
9
9
|
import { getFileKind } from '../engine/files';
|
|
10
|
+
import { hasEditorModule, loadEditorModule } from './editorRegistry';
|
|
10
11
|
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
11
12
|
import { collectAssets } from '../engine/assets';
|
|
12
13
|
import { MainEditor, styles } from '../engine/ui';
|
|
@@ -65,7 +66,33 @@ function useFileSaver() {
|
|
|
65
66
|
return { schedule, hasPending };
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
// A file type whose declaring deck named an editor module: load that module and
|
|
70
|
+
// render it, rather than matching the path against this kit's own list. This is
|
|
71
|
+
// what lets a deck hold two kits -- each type reaches its own declarer's editor,
|
|
72
|
+
// and neither kit has to know the other exists.
|
|
73
|
+
function DeclaredEditor({ path, module: modulePath, text, onChange, files, onChangeFile }) {
|
|
74
|
+
const [Editor, setEditor] = useState(null);
|
|
75
|
+
const [error, setError] = useState(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
let alive = true;
|
|
78
|
+
setEditor(null);
|
|
79
|
+
setError(null);
|
|
80
|
+
loadEditorModule(modulePath).then(
|
|
81
|
+
(loaded) => alive && setEditor(() => loaded),
|
|
82
|
+
(e) => alive && setError(e instanceof Error ? e.message : String(e)),
|
|
83
|
+
);
|
|
84
|
+
return () => {
|
|
85
|
+
alive = false;
|
|
86
|
+
};
|
|
87
|
+
}, [modulePath]);
|
|
88
|
+
if (error) return <div className={styles.editorBody}>{error}</div>;
|
|
89
|
+
if (!Editor) return <div className={styles.editorBody}>loading editor…</div>;
|
|
90
|
+
return (
|
|
91
|
+
<Editor path={path} text={text} onChange={onChange} files={files} onChangeFile={onChangeFile} />
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function SingleEditor({ path, editor, editorModule }) {
|
|
69
96
|
// Selection survives a code-change reload: stashed via the SDK's save-state
|
|
70
97
|
// hook right before the reload, picked back up here on boot.
|
|
71
98
|
const stashKey = `single-editor:${path}`;
|
|
@@ -96,7 +123,21 @@ export function SingleEditor({ path, editor }) {
|
|
|
96
123
|
const kind = editor || getFileKind(path);
|
|
97
124
|
const text = files[path] ?? '';
|
|
98
125
|
let body = null;
|
|
99
|
-
|
|
126
|
+
// A declared module wins over this kit's own extension matching -- including
|
|
127
|
+
// over its own types, so the kit's editors are reachable the same way anyone
|
|
128
|
+
// else's are once physics-2d names them in castle.json.
|
|
129
|
+
if (editorModule && hasEditorModule(editorModule)) {
|
|
130
|
+
body = (
|
|
131
|
+
<DeclaredEditor
|
|
132
|
+
path={path}
|
|
133
|
+
module={editorModule}
|
|
134
|
+
text={text}
|
|
135
|
+
onChange={onChange}
|
|
136
|
+
files={files}
|
|
137
|
+
onChangeFile={onChangeFile}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
} else if (kind === 'scene') {
|
|
100
141
|
body = (
|
|
101
142
|
<SceneEditor
|
|
102
143
|
path={path}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Every editor module any deck here declares -- this deck's own and each
|
|
2
|
+
// import's -- keyed by its path from the deck root, which is exactly what
|
|
3
|
+
// `castle.json` `editor` resolves to (see resolveFileTypes in the CLI).
|
|
4
|
+
//
|
|
5
|
+
// A glob rather than a dynamic `import(path)`: the specifier has to be static
|
|
6
|
+
// for the module to survive bundling into a deck's single published file, the
|
|
7
|
+
// same reason behaviorRegistry and files.js glob. NOT eager, so an editor is a
|
|
8
|
+
// separate chunk that the play route never evaluates.
|
|
9
|
+
//
|
|
10
|
+
// Editors live under `editors/` by convention; that is what makes a third
|
|
11
|
+
// party's editor reachable from here without this kit knowing it exists.
|
|
12
|
+
const modules = {
|
|
13
|
+
...import.meta.glob('/imports/*/editors/**/*.{jsx,js}'),
|
|
14
|
+
...import.meta.glob('/editors/**/*.{jsx,js}'),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function hasEditorModule(path) {
|
|
18
|
+
return Boolean(path && modules[`/${path}`]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Load one, returning its default export. Throws a legible error rather than
|
|
22
|
+
// letting the caller see `undefined is not a function` three frames later.
|
|
23
|
+
export async function loadEditorModule(path) {
|
|
24
|
+
const load = modules[`/${path}`];
|
|
25
|
+
if (!load) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`No editor module at "${path}". Editors must live under editors/ so the registry glob can reach them.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const mod = await load();
|
|
31
|
+
const Editor = mod.default ?? mod.Editor;
|
|
32
|
+
if (!Editor) throw new Error(`"${path}" has no default export to render as an editor.`);
|
|
33
|
+
return Editor;
|
|
34
|
+
}
|
package/kits/physics-2d/main.jsx
CHANGED
|
@@ -12,13 +12,22 @@ if (!root) throw new Error('Missing root element');
|
|
|
12
12
|
// File browsing and the code/text editor are now builtin shell panels, so the
|
|
13
13
|
// kit only renders the deck (play) and its rich per-file editors (scene/pxart):
|
|
14
14
|
// (no edit / ?edit=0) -> play the deck (playtest panel)
|
|
15
|
-
// ?file=<path>[&
|
|
15
|
+
// ?file=<path>[&editorModule=<path>] -> a single rich editor for that file
|
|
16
16
|
const params = new URLSearchParams(window.location.search);
|
|
17
17
|
function pick() {
|
|
18
18
|
const initialScene = params.get('scene') ?? undefined;
|
|
19
19
|
if (!isEdit()) return <PlayOnly initialScene={initialScene} />;
|
|
20
20
|
const file = params.get('file');
|
|
21
|
-
if (file)
|
|
21
|
+
if (file)
|
|
22
|
+
return (
|
|
23
|
+
<SingleEditor
|
|
24
|
+
path={file}
|
|
25
|
+
editor={params.get('editor') ?? undefined}
|
|
26
|
+
// Which module edits this file, resolved by the serve from whichever
|
|
27
|
+
// deck declared the type -- see `editor` in castle.json.
|
|
28
|
+
editorModule={params.get('editorModule') ?? undefined}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
22
31
|
return <PlayOnly initialScene={initialScene} />;
|
|
23
32
|
}
|
|
24
33
|
createRoot(root).render(<ErrorBoundary>{pick()}</ErrorBoundary>);
|