castle-web-cli 0.4.84 → 0.4.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
  9. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  10. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  11. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  12. package/kits/basic-2d/engine/collider.js +60 -6
  13. package/kits/basic-2d/engine/scene.js +28 -2
  14. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  15. package/kits/basic-2d/engine/ui.jsx +2 -0
  16. package/kits/basic-2d/main.jsx +3 -2
  17. package/kits/physics-2d/.prettierrc +8 -0
  18. package/kits/physics-2d/CLAUDE.md +329 -0
  19. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  20. package/kits/physics-2d/behaviors/Collider.jsx +213 -0
  21. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  22. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  23. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  24. package/kits/physics-2d/behaviors/tint.js +47 -0
  25. package/kits/physics-2d/blueprints/ball.scene +14 -0
  26. package/kits/physics-2d/blueprints/block.scene +12 -0
  27. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  28. package/kits/physics-2d/blueprints/crate.scene +14 -0
  29. package/kits/physics-2d/blueprints/goal.scene +12 -0
  30. package/kits/physics-2d/castle.json +13 -0
  31. package/kits/physics-2d/docs/pxart-format.md +377 -0
  32. package/kits/physics-2d/drawings/block.pxart +25 -0
  33. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  34. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  35. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  36. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  37. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  38. package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
  39. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  40. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  41. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  42. package/kits/physics-2d/editors/editorHistory.js +157 -0
  43. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  44. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  45. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  46. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  47. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  48. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  49. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  51. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  52. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  53. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  54. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  55. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  56. package/kits/physics-2d/engine/assets.js +15 -0
  57. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  58. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  59. package/kits/physics-2d/engine/blueprint.js +521 -0
  60. package/kits/physics-2d/engine/collider.js +200 -0
  61. package/kits/physics-2d/engine/files.js +117 -0
  62. package/kits/physics-2d/engine/liveReload.js +88 -0
  63. package/kits/physics-2d/engine/pxart.js +1032 -0
  64. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  65. package/kits/physics-2d/engine/scene.js +685 -0
  66. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  67. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  68. package/kits/physics-2d/engine/ui.jsx +688 -0
  69. package/kits/physics-2d/engine/ui.module.css +2287 -0
  70. package/kits/physics-2d/eslint.config.js +71 -0
  71. package/kits/physics-2d/index.html +24 -0
  72. package/kits/physics-2d/main.jsx +24 -0
  73. package/kits/physics-2d/package-lock.json +2706 -0
  74. package/kits/physics-2d/package.json +42 -0
  75. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  76. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  77. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  78. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  79. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  80. package/kits/physics-2d/physics/controls.js +79 -0
  81. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  82. package/kits/physics-2d/physics/index.js +26 -0
  83. package/kits/physics-2d/physics/matterBridge.js +126 -0
  84. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  85. package/kits/physics-2d/scenes/main.scene +12 -0
  86. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  87. package/kits/physics-2d/scripts/draw.mjs +121 -0
  88. package/kits/physics-2d/systems/physics.js +8 -0
  89. package/kits/physics-2d/vite.config.js +1 -0
  90. package/package.json +1 -1
@@ -0,0 +1,200 @@
1
+ // Single source of truth for the Collider rect. Lives in engine/ (not
2
+ // behaviors/) so SceneRuntime can use it directly, with behaviors/Collider.jsx
3
+ // (its `draw`, and its `getColliderRect`/`intersects` re-exports) delegating
4
+ // here instead of keeping a second, drifting copy.
5
+ import { frameCount, renderSpriteFrame } from './pxart';
6
+ import { spriteDestRect } from './spriteGeometry';
7
+
8
+ // Union bounding box (fraction of native resolution, 0..1) of every opaque
9
+ // (alpha > 0) pixel across ALL animation frames of a sprite, or null when the
10
+ // art is fully transparent. Unioning across frames keeps an animated sprite's
11
+ // collider from pulsing frame to frame. Cached in a WeakMap keyed by the
12
+ // sprite object -- a sprite edit produces a fresh object (same invalidation
13
+ // lifecycle as Sprite.jsx's rendered-canvas cache), so a stale entry never
14
+ // outlives the art it was computed from.
15
+ const opaqueBoundsCache = new WeakMap();
16
+
17
+ // Expand `acc` (in pixel units) to cover every opaque pixel of the canvas's
18
+ // current contents.
19
+ function accumulateOpaquePixels(acc, ctx, canvas) {
20
+ if (!ctx || canvas.width === 0 || canvas.height === 0) return;
21
+ const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
22
+ for (let y = 0; y < canvas.height; y++) {
23
+ for (let x = 0; x < canvas.width; x++) {
24
+ if (data[(y * canvas.width + x) * 4 + 3] === 0) continue;
25
+ if (x < acc.minX) acc.minX = x;
26
+ if (y < acc.minY) acc.minY = y;
27
+ if (x + 1 > acc.maxX) acc.maxX = x + 1;
28
+ if (y + 1 > acc.maxY) acc.maxY = y + 1;
29
+ }
30
+ }
31
+ }
32
+
33
+ function opaqueBoundsFraction(sprite) {
34
+ if (opaqueBoundsCache.has(sprite)) return opaqueBoundsCache.get(sprite);
35
+
36
+ const { width, height } = sprite.resolution;
37
+ const canvas = document.createElement('canvas');
38
+ const ctx = canvas.getContext('2d');
39
+ const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
40
+ const total = frameCount(sprite);
41
+ for (let frame = 0; frame < total; frame++) {
42
+ renderSpriteFrame(sprite, frame, canvas);
43
+ accumulateOpaquePixels(acc, ctx, canvas);
44
+ }
45
+
46
+ const bounds =
47
+ acc.minX <= acc.maxX && acc.minY <= acc.maxY && width > 0 && height > 0
48
+ ? {
49
+ x: acc.minX / width,
50
+ y: acc.minY / height,
51
+ width: (acc.maxX - acc.minX) / width,
52
+ height: (acc.maxY - acc.minY) / height,
53
+ }
54
+ : null;
55
+ opaqueBoundsCache.set(sprite, bounds);
56
+ return bounds;
57
+ }
58
+
59
+ // True when a resolved sprite has no opaque pixels across any frame -- fully
60
+ // transparent art (e.g. a brand-new blank drawing). The editor draws an
61
+ // empty-actor placeholder for these so a blank actor isn't invisible. Shares
62
+ // `opaqueBoundsFraction`'s per-sprite cache (a sprite edit yields a fresh
63
+ // object, which invalidates it).
64
+ export function spriteIsEmpty(sprite) {
65
+ return !sprite || opaqueBoundsFraction(sprite) === null;
66
+ }
67
+
68
+ // The `mode: 'auto'` rect (before offsets), in world units, or null when auto
69
+ // falls back to the full Layout box: no Sprite, an unresolved sprite file,
70
+ // `Sprite.mode === 'tile'` (tiled art fills its cell by definition, so a
71
+ // per-cell bbox doesn't compose), or fully transparent art. Deliberately does
72
+ // NOT follow Sprite.jsx's FALLBACK_FILE placeholder-art fallback -- an
73
+ // unresolvable file means the Layout box, period.
74
+ function autoRect(layout, spriteProps, sprite) {
75
+ if (!sprite || spriteProps.mode === 'tile') return null;
76
+ const bounds = opaqueBoundsFraction(sprite);
77
+ if (!bounds) return null;
78
+ const dest = spriteDestRect(spriteProps.mode, layout, sprite.resolution);
79
+ const rect = {
80
+ x: dest.x + bounds.x * dest.width,
81
+ y: dest.y + bounds.y * dest.height,
82
+ width: bounds.width * dest.width,
83
+ height: bounds.height * dest.height,
84
+ };
85
+ // `cover`'s dest overflows the Layout box and the draw clips to it, so the
86
+ // opaque-bounds rect can spill outside the actor -- clamp to the visible box.
87
+ // A no-op for `stretch`/`fit`, whose dest already sits inside the box.
88
+ if (spriteProps.mode === 'cover') return intersectRect(rect, layout);
89
+ return rect;
90
+ }
91
+
92
+ // Rect intersection in world units; zero-size (never negative) when disjoint.
93
+ function intersectRect(rect, box) {
94
+ const x0 = Math.max(rect.x, box.x);
95
+ const y0 = Math.max(rect.y, box.y);
96
+ const x1 = Math.min(rect.x + rect.width, box.x + box.width);
97
+ const y1 = Math.min(rect.y + rect.height, box.y + box.height);
98
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
99
+ }
100
+
101
+ // `mode: 'manual'` rect (before offsets): `width`/`height`, centered in the
102
+ // Layout box.
103
+ function manualRect(layout, collider) {
104
+ const width = collider.width ?? layout.width;
105
+ const height = collider.height ?? layout.height;
106
+ return {
107
+ x: layout.x + (layout.width - width) / 2,
108
+ y: layout.y + (layout.height - height) / 2,
109
+ width,
110
+ height,
111
+ };
112
+ }
113
+
114
+ const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
115
+
116
+ // The un-offset rect. Colliders are sized by their explicit `width`/`height`
117
+ // (the "auto vs manual" mode is gone -- use the inspector's "Auto-fit to sprite"
118
+ // action to snap those to the sprite). Legacy decks that still carry
119
+ // `mode: 'auto'` keep their dynamic sprite fit for back-compat.
120
+ function modeRect(layout, collider, spriteProps, sprites) {
121
+ if (collider.mode === 'auto') {
122
+ const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
123
+ return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
124
+ }
125
+ return manualRect(layout, collider);
126
+ }
127
+
128
+ // The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
129
+ // `mode: 'auto'`), or null if it lacks a Layout or Collider. `sprites` is the
130
+ // scene's sprite map (file path -> parsed sprite); omit it (or pass a map
131
+ // missing the actor's file) to force the Layout-box fallback, e.g. from a
132
+ // caller that only has raw scene data and no loaded sprites.
133
+ export function getColliderRect(actor, sprites) {
134
+ const layout = actor?.components?.Layout;
135
+ const collider = actor?.components?.Collider;
136
+ if (!layout || !collider) return null;
137
+
138
+ const rect = modeRect(layout, collider, actor.components.Sprite, sprites);
139
+ return {
140
+ x: rect.x + (collider.offsetX ?? 0),
141
+ y: rect.y + (collider.offsetY ?? 0),
142
+ width: rect.width,
143
+ height: rect.height,
144
+ };
145
+ }
146
+
147
+ // Full collider geometry: the AABB rect (`x/y/width/height`, offset-applied)
148
+ // PLUS the shape and, for circles, the center + radius. This is the SINGLE
149
+ // SOURCE OF TRUTH for collider shape -- the physics body (matterBridge), the
150
+ // play-mode debug draw (Collider.draw), and the editor selection overlay all
151
+ // derive their geometry from here, so the visual preview always matches the
152
+ // simulated collider. `radius` of 0 means "derive from the rect" (min side / 2),
153
+ // so a circle shows a real size even before you set an explicit radius.
154
+ export function getColliderShape(actor, sprites) {
155
+ const rect = getColliderRect(actor, sprites);
156
+ if (!rect) return null;
157
+ const collider = actor.components.Collider;
158
+ const shape = collider.shape === 'circle' ? 'circle' : 'box';
159
+ const radius = collider.radius > 0 ? collider.radius : Math.min(rect.width, rect.height) / 2;
160
+ return {
161
+ shape,
162
+ x: rect.x,
163
+ y: rect.y,
164
+ width: rect.width,
165
+ height: rect.height,
166
+ cx: rect.x + rect.width / 2,
167
+ cy: rect.y + rect.height / 2,
168
+ radius,
169
+ };
170
+ }
171
+
172
+ // The explicit `width`/`height`/`offsetX`/`offsetY` a collider would need to
173
+ // exactly match its sprite's opaque-pixel fit -- what "auto" used to compute
174
+ // dynamically. Returns null when there's nothing to fit to (no Sprite, tile
175
+ // mode, or fully transparent art). Powers the inspector's "Auto-fit to sprite".
176
+ export function computeAutoFit(actor, sprites) {
177
+ const rawLayout = actor?.components?.Layout;
178
+ const spriteProps = actor?.components?.Sprite;
179
+ if (!rawLayout || !spriteProps) return null;
180
+ // Blueprint templates omit x/y (position is instance-local). x/y cancel out of
181
+ // the offset delta below, so default them to 0 -- otherwise an undefined x/y
182
+ // (auto-fitting a template, e.g. on add) turns the offsets into NaN.
183
+ const layout = { ...rawLayout, x: rawLayout.x ?? 0, y: rawLayout.y ?? 0 };
184
+ const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
185
+ if (!rect || rect.width <= 0 || rect.height <= 0) return null;
186
+ // manualRect centers a width x height box in the Layout box; the offset is the
187
+ // delta from that centered position to the sprite-fit rect.
188
+ const centeredX = layout.x + (layout.width - rect.width) / 2;
189
+ const centeredY = layout.y + (layout.height - rect.height) / 2;
190
+ return {
191
+ width: Math.round(rect.width),
192
+ height: Math.round(rect.height),
193
+ offsetX: Math.round(rect.x - centeredX),
194
+ offsetY: Math.round(rect.y - centeredY),
195
+ };
196
+ }
197
+
198
+ export function intersects(a, b) {
199
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
200
+ }
@@ -0,0 +1,117 @@
1
+ // Seed the file map by scanning the deck dir. Vite module-cache is invalidated
2
+ // on restart, so a newly-created file just shows up on the next reload.
3
+ const rawModules = import.meta.glob(
4
+ ['../scenes/*.scene', '../blueprints/*.scene', '../drawings/*.pxart', '../behaviors/*.jsx'],
5
+ { query: '?raw', import: 'default', eager: true }
6
+ );
7
+ export const initialFiles = Object.fromEntries(
8
+ Object.entries(rawModules)
9
+ .map(([globPath, text]) => [globPath.replace(/^\.\.\//, ''), text])
10
+ .sort(([a], [b]) => a.localeCompare(b))
11
+ );
12
+ export function getFileKind(path) {
13
+ if (path.endsWith('.scene')) return 'scene';
14
+ if (path.endsWith('.pxart')) return 'pxart';
15
+ if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
16
+ return 'text';
17
+ }
18
+ export function parseJsonFile(path, text) {
19
+ try {
20
+ return { value: JSON.parse(text), error: null };
21
+ } catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ return { value: null, error: `${path}: ${message}` };
24
+ }
25
+ }
26
+ export function formatJson(value) {
27
+ return `${JSON.stringify(value, null, 2)}\n`;
28
+ }
29
+ export function basename(path) {
30
+ return path.split('/').pop() ?? path;
31
+ }
32
+ // Parent directory of a path ('' for a root-level path).
33
+ export function dirname(path) {
34
+ const index = path.lastIndexOf('/');
35
+ return index === -1 ? '' : path.slice(0, index);
36
+ }
37
+ // Join a directory and a name, tolerating a '' (root) directory.
38
+ export function joinPath(dir, name) {
39
+ return dir ? `${dir}/${name}` : name;
40
+ }
41
+ // Split a filename into stem + extension (the extension includes the leading
42
+ // dot). Leading-dot names (e.g. '.gitignore') count as all-stem.
43
+ export function splitFileName(name) {
44
+ const dot = name.lastIndexOf('.');
45
+ if (dot <= 0) return { stem: name, ext: '' };
46
+ return { stem: name.slice(0, dot), ext: name.slice(dot) };
47
+ }
48
+ // A filename in `dir` that doesn't collide with any existing path. If
49
+ // `desiredName` is free it's returned as-is; otherwise '-2', '-3', ... is
50
+ // inserted before the extension until a free name is found.
51
+ export function uniqueFileName(existingPaths, dir, desiredName) {
52
+ const taken = new Set(existingPaths);
53
+ if (!taken.has(joinPath(dir, desiredName))) return desiredName;
54
+ const { stem, ext } = splitFileName(desiredName);
55
+ for (let n = 2; ; n++) {
56
+ const candidate = `${stem}-${n}${ext}`;
57
+ if (!taken.has(joinPath(dir, candidate))) return candidate;
58
+ }
59
+ }
60
+ // Top-level child segment names (files and virtual folders) directly inside
61
+ // `parentDir`. Used to keep new sibling folder names collision-free.
62
+ export function childNamesIn(existingPaths, parentDir) {
63
+ const prefix = parentDir ? `${parentDir}/` : '';
64
+ const names = new Set();
65
+ for (const path of existingPaths) {
66
+ if (prefix && !path.startsWith(prefix)) continue;
67
+ const segment = path.slice(prefix.length).split('/')[0];
68
+ if (segment) names.add(segment);
69
+ }
70
+ return names;
71
+ }
72
+ // A folder name inside `parentDir` that doesn't collide with an existing
73
+ // sibling file or folder. Appends '-2', '-3', ... until free.
74
+ export function uniqueFolderName(existingPaths, parentDir, desiredName) {
75
+ const taken = childNamesIn(existingPaths, parentDir);
76
+ if (!taken.has(desiredName)) return desiredName;
77
+ for (let n = 2; ; n++) {
78
+ const candidate = `${desiredName}-${n}`;
79
+ if (!taken.has(candidate)) return candidate;
80
+ }
81
+ }
82
+ // All file paths under a virtual folder (paths prefixed with `dirPath/`).
83
+ export function filesUnder(existingPaths, dirPath) {
84
+ const prefix = `${dirPath}/`;
85
+ return existingPaths.filter((path) => path.startsWith(prefix));
86
+ }
87
+ // Flat list of file paths in the order FileBrowser renders them: a
88
+ // depth-first walk of the directory tree, mirroring buildFileTree there.
89
+ export function flatFileOrder(paths) {
90
+ const root = { isFile: false, path: '', children: [], childMap: new Map() };
91
+ for (const path of paths) {
92
+ const parts = path.split('/');
93
+ let parent = root;
94
+ for (let index = 0; index < parts.length; index++) {
95
+ const name = parts[index];
96
+ const nodePath = parts.slice(0, index + 1).join('/');
97
+ const isFile = index === parts.length - 1;
98
+ if (!parent.childMap.has(name)) {
99
+ const node = { isFile, path: nodePath, children: [], childMap: new Map() };
100
+ parent.childMap.set(name, node);
101
+ parent.children.push(node);
102
+ }
103
+ const child = parent.childMap.get(name);
104
+ if (!child || child.isFile) break;
105
+ parent = child;
106
+ }
107
+ }
108
+ const order = [];
109
+ const walk = (node) => {
110
+ for (const child of node.children) {
111
+ if (child.isFile) order.push(child.path);
112
+ else walk(child);
113
+ }
114
+ };
115
+ walk(root);
116
+ return order;
117
+ }
@@ -0,0 +1,88 @@
1
+ // Consumer side of the serve's `files_changed` broadcast. The engine decides
2
+ // what a change means for this bundle:
3
+ // - data files (.scene / .pxart / .drawing) are re-read over the serve's
4
+ // files API and folded into React state — live re-render, no reload.
5
+ // - code the bundle imports (per the event's module-graph closure), html,
6
+ // or added/deleted code files (import.meta.glob may pick them up) mean
7
+ // this context is stale — requestReload() (save-state hooks run first).
8
+ import { useEffect, useRef, useState } from 'react';
9
+ import { onFilesChanged, requestReload } from 'castle-web-sdk';
10
+ import { initialFiles } from './files';
11
+
12
+ const DATA_EXTS = ['.scene', '.pxart', '.drawing'];
13
+ const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
14
+
15
+ export function isDataFile(path) {
16
+ return DATA_EXTS.some((ext) => path.endsWith(ext));
17
+ }
18
+
19
+ function needsContextReload(change) {
20
+ if (isDataFile(change.path)) return false;
21
+ if (change.path.endsWith('.html')) return true;
22
+ // In the module graph (directly or as someone's import) -> our code changed.
23
+ if (change.affected.length > 0) return true;
24
+ // A new/removed code file isn't in the graph yet but a glob may scan it.
25
+ if (change.event !== 'change') return CODE_EXTS.some((ext) => change.path.endsWith(ext));
26
+ return false;
27
+ }
28
+
29
+ async function readDeckFile(path) {
30
+ const res = await fetch(`/__castle/files/read?path=${encodeURIComponent(path)}`);
31
+ if (!res.ok) throw new Error(`read failed: ${res.status}`);
32
+ const data = await res.json();
33
+ return typeof data.contents === 'string' ? data.contents : '';
34
+ }
35
+
36
+ // Deck files as live state: starts from the build-time glob snapshot and stays
37
+ // current as files change on disk from any source (editors, agents, terminal).
38
+ // `shouldSkipPath` lets an editor keep its own in-flight edits from being
39
+ // clobbered by the fs echo of a stale write. `dataVersion` bumps on every
40
+ // applied data change — key a player on it to rebuild from fresh data.
41
+ export function useLiveDeckFiles({ shouldSkipPath } = {}) {
42
+ const [files, setFiles] = useState(initialFiles);
43
+ const [dataVersion, setDataVersion] = useState(0);
44
+ const skipRef = useRef(shouldSkipPath);
45
+ skipRef.current = shouldSkipPath;
46
+
47
+ useEffect(
48
+ () =>
49
+ onFilesChanged((event) => {
50
+ if (event.changes.some(needsContextReload)) {
51
+ requestReload();
52
+ return;
53
+ }
54
+ const dataChanges = event.changes.filter(
55
+ (change) => isDataFile(change.path) && !skipRef.current?.(change.path)
56
+ );
57
+ if (dataChanges.length === 0) return;
58
+ void applyDataChanges(dataChanges, setFiles, setDataVersion);
59
+ }),
60
+ []
61
+ );
62
+
63
+ return { files, setFiles, dataVersion };
64
+ }
65
+
66
+ async function applyDataChanges(changes, setFiles, setDataVersion) {
67
+ const updates = await Promise.all(
68
+ changes.map(async (change) => {
69
+ if (change.event === 'delete') return { path: change.path, text: null };
70
+ try {
71
+ return { path: change.path, text: await readDeckFile(change.path) };
72
+ } catch {
73
+ return null; // read raced a delete / serve hiccup — skip this path
74
+ }
75
+ })
76
+ );
77
+ const applied = updates.filter(Boolean);
78
+ if (applied.length === 0) return;
79
+ setFiles((current) => {
80
+ const next = { ...current };
81
+ for (const { path, text } of applied) {
82
+ if (text === null) delete next[path];
83
+ else next[path] = text;
84
+ }
85
+ return next;
86
+ });
87
+ setDataVersion((version) => version + 1);
88
+ }