castle-web-cli 0.4.75 → 0.4.76

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 (68) hide show
  1. package/dist/agent-prompts.d.ts +4 -0
  2. package/dist/agent-prompts.js +20 -8
  3. package/dist/agent.js +301 -28
  4. package/dist/castle-host/host.js +59 -5
  5. package/dist/commonInstructions.d.ts +1 -1
  6. package/dist/commonInstructions.js +4 -0
  7. package/dist/ide.d.ts +1 -0
  8. package/dist/ide.js +250 -1
  9. package/dist/init.js +46 -4
  10. package/dist/save-deck.js +8 -1
  11. package/dist/serve.js +27 -1
  12. package/dist/shell/assets/index-DNWEQd4R.js +141 -0
  13. package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/basic-2d/CLAUDE.md +13 -5
  16. package/kits/basic-2d/castle.json +15 -0
  17. package/kits/basic-2d/drawings/pig.pxart +22 -55
  18. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  19. package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
  20. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  21. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  22. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  23. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  24. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
  25. package/kits/basic-2d/engine/ui.jsx +15 -0
  26. package/kits/basic-2d/engine/ui.module.css +44 -117
  27. package/kits/basic-2d/main.jsx +6 -10
  28. package/package.json +10 -1
  29. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  30. package/kits/basic-2d/editors/App.jsx +0 -226
  31. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  32. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  33. package/kits/basic-2d/editors/codeTheme.js +0 -135
  34. package/kits/basic-2d/engine/playConsole.js +0 -66
  35. package/kits/basic-3d/.prettierrc +0 -8
  36. package/kits/basic-3d/CLAUDE.md +0 -162
  37. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  38. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  39. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  40. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  41. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  42. package/kits/basic-3d/editors/App.jsx +0 -147
  43. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  44. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  45. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  46. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  47. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
  48. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  49. package/kits/basic-3d/editors/editorHistory.js +0 -52
  50. package/kits/basic-3d/editors/viewportRig.js +0 -90
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
  52. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  53. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  54. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  55. package/kits/basic-3d/engine/files.js +0 -73
  56. package/kits/basic-3d/engine/scene.js +0 -502
  57. package/kits/basic-3d/engine/threeUtil.js +0 -260
  58. package/kits/basic-3d/engine/ui.jsx +0 -352
  59. package/kits/basic-3d/engine/ui.module.css +0 -944
  60. package/kits/basic-3d/eslint.config.js +0 -51
  61. package/kits/basic-3d/index.html +0 -11
  62. package/kits/basic-3d/main.jsx +0 -10
  63. package/kits/basic-3d/models/block.model +0 -14
  64. package/kits/basic-3d/package-lock.json +0 -2713
  65. package/kits/basic-3d/package.json +0 -41
  66. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  67. package/kits/basic-3d/scenes/main.scene +0 -76
  68. package/kits/basic-3d/vite.config.js +0 -1
@@ -1,226 +0,0 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import { onBeforeRestart, writeFile } from 'castle-web-sdk';
3
- import { flatFileOrder, formatJson, getFileKind, initialFiles } from '../engine/files';
4
- import { collectAssets } from '../engine/assets';
5
- import { AppShell, cx, MainEditor, styles, useCompactViewport } from '../engine/ui';
6
- import { CodeEditor } from './CodeEditor';
7
- import { PxArtEditor } from './PxArtEditor';
8
- import { FileBrowser } from './FileBrowser';
9
- import { SceneEditor } from './SceneEditor';
10
- // Desktop file-list open/closed state persists across reloads; the bottom
11
- // sheet (compact) is always session-only.
12
- const FILES_OPEN_KEY = 'castle-basic2d-files-open';
13
- const SELECTED_PATH_KEY = 'castle-basic2d-selected-path';
14
- function readFilesOpen() {
15
- try {
16
- return localStorage.getItem(FILES_OPEN_KEY) !== '0';
17
- } catch {
18
- return true;
19
- }
20
- }
21
- function readSelectedPath() {
22
- try {
23
- const saved = localStorage.getItem(SELECTED_PATH_KEY);
24
- return saved && initialFiles[saved] !== undefined ? saved : 'scenes/main.scene';
25
- } catch {
26
- return 'scenes/main.scene';
27
- }
28
- }
29
- export function App() {
30
- const [files, setFiles] = useState(initialFiles);
31
- const [selectedPath, setSelectedPath] = useState(readSelectedPath);
32
- const [filesSheetOpen, setFilesSheetOpen] = useState(false);
33
- const [filesOpenDesktop, setFilesOpenDesktop] = useState(readFilesOpen);
34
- const [headerHost, setHeaderHost] = useState(null);
35
- const isCompact = useCompactViewport();
36
- const [selectedActorIds, setSelectedActorIds] = useState([]);
37
- const [multiSelectMode, setMultiSelectMode] = useState(false);
38
- const saveTimersRef = useRef({});
39
- const saveVersionsRef = useRef({});
40
- const pendingWritesRef = useRef({});
41
- const { sprites } = collectAssets(files);
42
- function updateSelectedFile(nextText) {
43
- const path = selectedPath;
44
- setFiles((current) => ({
45
- ...current,
46
- [path]: nextText,
47
- }));
48
- scheduleFileWrite(path, nextText);
49
- }
50
- function commitFileWrite(path, text, version) {
51
- return writeFile(path, text)
52
- .then(() => {
53
- if (saveVersionsRef.current[path] === version && pendingWritesRef.current[path] === text) {
54
- delete pendingWritesRef.current[path];
55
- }
56
- })
57
- .catch((error) => {
58
- if (saveVersionsRef.current[path] !== version) return;
59
- const message = error instanceof Error ? error.message : String(error);
60
- console.error(`Failed to save ${path}: ${message}`);
61
- });
62
- }
63
- function scheduleFileWrite(path, nextText) {
64
- const version = (saveVersionsRef.current[path] ?? 0) + 1;
65
- saveVersionsRef.current[path] = version;
66
- pendingWritesRef.current[path] = nextText;
67
- const existingTimer = saveTimersRef.current[path];
68
- if (existingTimer) window.clearTimeout(existingTimer);
69
- saveTimersRef.current[path] = window.setTimeout(() => {
70
- delete saveTimersRef.current[path];
71
- void commitFileWrite(path, nextText, version);
72
- }, 1500);
73
- }
74
- // A reload (npm run restart) can land mid-debounce; flush unsaved edits to
75
- // disk before the page reloads so in-progress editor work isn't lost.
76
- useEffect(() => {
77
- return onBeforeRestart(async () => {
78
- for (const timer of Object.values(saveTimersRef.current)) window.clearTimeout(timer);
79
- saveTimersRef.current = {};
80
- const pending = Object.entries(pendingWritesRef.current);
81
- await Promise.all(
82
- pending.map(([path, text]) =>
83
- commitFileWrite(path, text, saveVersionsRef.current[path] ?? 0)
84
- )
85
- );
86
- });
87
- }, []);
88
- const kind = getFileKind(selectedPath);
89
- const text = files[selectedPath] ?? '';
90
- // The PxArt pixel editor handles `.pxart` files; pick it by kind and render a
91
- // single block.
92
- const PixelEditor = kind === 'pxart' ? PxArtEditor : null;
93
- // Move selection to `path` and persist it, without the extra resets
94
- // selectFile applies. Used by file ops that should keep the same item
95
- // selected across a rename or pick a neighbor after a delete.
96
- function rememberSelected(path) {
97
- setSelectedPath(path);
98
- try {
99
- localStorage.setItem(SELECTED_PATH_KEY, path);
100
- } catch {
101
- // Debug convenience only; selecting files still works in-session.
102
- }
103
- }
104
- function selectFile(path) {
105
- rememberSelected(path);
106
- setFilesSheetOpen(false);
107
- setSelectedActorIds([]);
108
- setMultiSelectMode(false);
109
- }
110
- // One toggle, two behaviors: compact opens/closes the file-list bottom
111
- // sheet (and deselects, since the sheet covers the canvas); desktop collapses
112
- // the 236px file-list column and persists that to localStorage.
113
- const onToggleFiles = () => {
114
- if (isCompact) {
115
- setFilesSheetOpen((previous) => !previous);
116
- setSelectedActorIds([]);
117
- setMultiSelectMode(false);
118
- return;
119
- }
120
- setFilesOpenDesktop((previous) => {
121
- const next = !previous;
122
- try {
123
- localStorage.setItem(FILES_OPEN_KEY, next ? '1' : '0');
124
- } catch {
125
- // localStorage may be unavailable; the toggle still works in-session.
126
- }
127
- return next;
128
- });
129
- };
130
- const filesShown = isCompact ? filesSheetOpen : filesOpenDesktop;
131
- // Alt+Up / Alt+Down steps through files in file-browser sidebar order.
132
- useEffect(() => {
133
- function onKeyDown(event) {
134
- if (!event.altKey || event.metaKey || event.ctrlKey) return;
135
- if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
136
- const active = document.activeElement;
137
- if (
138
- active &&
139
- (active.tagName === 'INPUT' ||
140
- active.tagName === 'TEXTAREA' ||
141
- active.tagName === 'SELECT' ||
142
- active.isContentEditable ||
143
- active.closest('.cm-editor'))
144
- ) {
145
- return;
146
- }
147
- const order = flatFileOrder(Object.keys(files));
148
- const index = order.indexOf(selectedPath);
149
- if (index === -1) return;
150
- const nextIndex = event.key === 'ArrowUp' ? index - 1 : index + 1;
151
- if (nextIndex < 0 || nextIndex >= order.length) return;
152
- event.preventDefault();
153
- selectFile(order[nextIndex]);
154
- }
155
- window.addEventListener('keydown', onKeyDown);
156
- return () => window.removeEventListener('keydown', onKeyDown);
157
- }, [files, selectedPath]);
158
- return (
159
- <div className={cx(styles.editorRoot, !isCompact && !filesOpenDesktop && styles.filesHidden)}>
160
- <div ref={setHeaderHost} className={styles.editorHeaderHost} />
161
- <AppShell>
162
- <FileBrowser
163
- files={files}
164
- selectedPath={selectedPath}
165
- onSelect={selectFile}
166
- sheetOpen={filesSheetOpen}
167
- onSheetOpenChange={setFilesSheetOpen}
168
- />
169
- {filesSheetOpen ? (
170
- <div
171
- className={cx(styles.sheetBackdrop, styles.mobileOnly)}
172
- onClick={() => setFilesSheetOpen(false)}
173
- />
174
- ) : null}
175
- <MainEditor>
176
- {kind === 'scene' ? (
177
- <SceneEditor
178
- path={selectedPath}
179
- text={text}
180
- files={files}
181
- sprites={sprites}
182
- onChange={updateSelectedFile}
183
- onToggleFiles={onToggleFiles}
184
- filesOpen={filesShown}
185
- headerHost={headerHost}
186
- selectedActorIds={selectedActorIds}
187
- onSelectActorIds={setSelectedActorIds}
188
- multiSelectMode={multiSelectMode}
189
- onSetMultiSelectMode={setMultiSelectMode}
190
- />
191
- ) : null}
192
- {PixelEditor ? (
193
- <PixelEditor
194
- path={selectedPath}
195
- text={text}
196
- onChange={updateSelectedFile}
197
- onToggleFiles={onToggleFiles}
198
- filesOpen={filesShown}
199
- headerHost={headerHost}
200
- />
201
- ) : null}
202
- {kind === 'code' ? (
203
- <CodeEditor
204
- path={selectedPath}
205
- text={text}
206
- onChange={updateSelectedFile}
207
- onToggleFiles={onToggleFiles}
208
- filesOpen={filesShown}
209
- headerHost={headerHost}
210
- />
211
- ) : null}
212
- {kind === 'text' ? (
213
- <CodeEditor
214
- path={selectedPath}
215
- text={formatJson(text)}
216
- onChange={updateSelectedFile}
217
- onToggleFiles={onToggleFiles}
218
- filesOpen={filesShown}
219
- headerHost={headerHost}
220
- />
221
- ) : null}
222
- </MainEditor>
223
- </AppShell>
224
- </div>
225
- );
226
- }
@@ -1,79 +0,0 @@
1
- import React, { useEffect, useRef } from 'react';
2
- import { javascript } from '@codemirror/lang-javascript';
3
- import { indentUnit, syntaxHighlighting } from '@codemirror/language';
4
- import { EditorState } from '@codemirror/state';
5
- import { EditorView } from '@codemirror/view';
6
- import { basicSetup } from 'codemirror';
7
- import { basename } from '../engine/files';
8
- import { EditorBody, EditorHeader, styles } from '../engine/ui';
9
- import { castleCodeTheme, castleHighlightStyle } from './codeTheme';
10
- export function CodeEditor({ path, text, onChange, onToggleFiles, filesOpen, headerHost }) {
11
- const editorRef = useRef(null);
12
- const viewRef = useRef(null);
13
- const onChangeRef = useRef(onChange);
14
- const initialTextRef = useRef(text);
15
- const applyingExternalTextRef = useRef(false);
16
- useEffect(() => {
17
- onChangeRef.current = onChange;
18
- }, [onChange]);
19
- // The editor is created once per `path`; the initial doc comes from a ref
20
- // so `text` is not a dep of this effect (subsequent `text` updates flow
21
- // through the second effect below). On `path` change we read whatever the
22
- // latest `text` is at the moment of mount.
23
- initialTextRef.current = text;
24
- useEffect(() => {
25
- if (!editorRef.current) return undefined;
26
- const view = new EditorView({
27
- parent: editorRef.current,
28
- state: EditorState.create({
29
- doc: initialTextRef.current,
30
- extensions: [
31
- basicSetup,
32
- javascript({ jsx: true, typescript: true }),
33
- indentUnit.of(' '),
34
- castleCodeTheme,
35
- syntaxHighlighting(castleHighlightStyle),
36
- EditorView.updateListener.of((update) => {
37
- if (!update.docChanged || applyingExternalTextRef.current) return;
38
- onChangeRef.current(update.state.doc.toString());
39
- }),
40
- ],
41
- }),
42
- });
43
- viewRef.current = view;
44
- return () => {
45
- view.destroy();
46
- viewRef.current = null;
47
- };
48
- }, [path]);
49
- useEffect(() => {
50
- const view = viewRef.current;
51
- if (!view) return;
52
- const currentText = view.state.doc.toString();
53
- if (currentText === text) return;
54
- applyingExternalTextRef.current = true;
55
- view.dispatch({
56
- changes: {
57
- from: 0,
58
- to: currentText.length,
59
- insert: text,
60
- },
61
- });
62
- applyingExternalTextRef.current = false;
63
- }, [text]);
64
- return (
65
- <>
66
- <EditorHeader
67
- title={basename(path)}
68
- onToggleFiles={onToggleFiles}
69
- filesOpen={filesOpen}
70
- headerHost={headerHost}
71
- />
72
- <EditorBody>
73
- <div className={styles.codeEditor}>
74
- <div ref={editorRef} className={styles.codeMirrorHost} />
75
- </div>
76
- </EditorBody>
77
- </>
78
- );
79
- }
@@ -1,349 +0,0 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import { basename } from '../engine/files';
3
- import { ContextMenu, cx, Icon, SheetGrabHandle, styles, useMobileSheet } from '../engine/ui';
4
- // The file tree plus its right-click menus and inline rename. By default it
5
- // renders inside an `aside` with mobile-sheet chrome (the combined editor's
6
- // sidebar). Pass `bare` to drop that chrome and render just the tree + menus so
7
- // it can be mounted on its own in the dockview "Files" panel.
8
- //
9
- // The file-op props (onAddFile / onDuplicate / onRename / onDelete) are
10
- // OPTIONAL. When none are provided the browser is read-only: no context menus,
11
- // no inline rename, no add affordances -- just a browsable, clickable tree.
12
- export function FileBrowser({
13
- files,
14
- selectedPath,
15
- onSelect,
16
- onAddFile,
17
- onDuplicate,
18
- onRename,
19
- onDelete,
20
- sheetOpen = false,
21
- onSheetOpenChange,
22
- bare = false,
23
- }) {
24
- const canMutate = Boolean(onAddFile || onDuplicate || onRename || onDelete);
25
- const tree = buildFileTree(Object.keys(files));
26
- const [expanded, setExpanded] = useState(() => new Set(collectDirectoryPaths(tree)));
27
- // `menu` is { x, y, node } for a row menu, or { x, y, node: null } for the
28
- // panel-background menu. `renaming` holds the path whose row is in edit mode.
29
- const [menu, setMenu] = useState(null);
30
- const [renaming, setRenaming] = useState(null);
31
- const sheet = useMobileSheet({
32
- snap: sheetOpen ? 'high' : 'hidden',
33
- baseClassName: styles.fileBrowser,
34
- onTransition: (direction) => {
35
- if (direction !== 'up') onSheetOpenChange?.(false);
36
- },
37
- });
38
- function toggle(path) {
39
- setExpanded((current) => {
40
- const next = new Set(current);
41
- if (next.has(path)) next.delete(path);
42
- else next.add(path);
43
- return next;
44
- });
45
- }
46
- function expandDir(path) {
47
- if (!path) return;
48
- setExpanded((current) => new Set(current).add(path));
49
- }
50
- function openMenu(event, node) {
51
- if (!canMutate) return;
52
- event.preventDefault();
53
- event.stopPropagation();
54
- setMenu({ x: event.clientX, y: event.clientY, node });
55
- }
56
- function handleAddFile(dirPath) {
57
- onAddFile?.(dirPath);
58
- expandDir(dirPath);
59
- }
60
- function handleDuplicate(node) {
61
- const newPath = onDuplicate?.(node);
62
- if (node.type === 'directory' && newPath) expandDir(newPath);
63
- }
64
- function submitRename(node, rawName) {
65
- setRenaming(null);
66
- const newName = rawName.trim();
67
- if (!newName || newName === node.name) return;
68
- const newPath = onRename?.(node, newName);
69
- if (node.type === 'directory' && newPath && newPath !== node.path) {
70
- setExpanded((current) => repathExpanded(current, node.path, newPath));
71
- }
72
- }
73
- function menuItems(node) {
74
- const fileOnly = [
75
- { key: 'duplicate', label: 'Duplicate', onClick: () => handleDuplicate(node) },
76
- { key: 'rename', label: 'Rename', onClick: () => setRenaming(node.path) },
77
- { key: 'delete', label: 'Delete', onClick: () => onDelete?.(node) },
78
- ];
79
- if (!node) return [{ key: 'add', label: 'Add file', onClick: () => handleAddFile('') }];
80
- if (node.type === 'directory') {
81
- return [
82
- { key: 'add', label: 'Add file', onClick: () => handleAddFile(node.path) },
83
- ...fileOnly,
84
- ];
85
- }
86
- return fileOnly;
87
- }
88
- const treeNodes = (
89
- <div className={styles.fileTree}>
90
- {tree.children.map((node) => (
91
- <FileNode
92
- key={node.path}
93
- node={node}
94
- depth={0}
95
- expanded={expanded}
96
- selectedPath={selectedPath}
97
- renaming={renaming}
98
- onSelect={onSelect}
99
- onToggle={toggle}
100
- onContextMenu={openMenu}
101
- onRenameSubmit={submitRename}
102
- onRenameCancel={() => setRenaming(null)}
103
- />
104
- ))}
105
- </div>
106
- );
107
- const menuEl = menu ? (
108
- <ContextMenu x={menu.x} y={menu.y} items={menuItems(menu.node)} onClose={() => setMenu(null)} />
109
- ) : null;
110
- // Panel mount: no sheet/grab-handle chrome, just a full-height tree whose
111
- // empty area below the rows opens the blank-area ("Add file") menu.
112
- if (bare) {
113
- return (
114
- <div className={styles.fileBrowserBare} onContextMenu={(event) => openMenu(event, null)}>
115
- {treeNodes}
116
- {menuEl}
117
- </div>
118
- );
119
- }
120
- return (
121
- <aside {...sheet.rootProps} onContextMenu={(event) => openMenu(event, null)}>
122
- <div {...sheet.grabProps}>
123
- <SheetGrabHandle label="Files" hint={basename(selectedPath)} />
124
- </div>
125
- {treeNodes}
126
- {menuEl}
127
- </aside>
128
- );
129
- }
130
- function FileNode({
131
- node,
132
- depth,
133
- expanded,
134
- selectedPath,
135
- renaming,
136
- onSelect,
137
- onToggle,
138
- onContextMenu,
139
- onRenameSubmit,
140
- onRenameCancel,
141
- }) {
142
- const depthStyle = { '--file-depth': depth };
143
- const isRenaming = renaming === node.path;
144
- if (node.type === 'directory') {
145
- const isExpanded = expanded.has(node.path);
146
- const label = isRenaming ? (
147
- <span className={styles.fileLabel}>
148
- <span className={cx(styles.fileIcon, styles.fileDisclosure)}>
149
- <Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} />
150
- </span>
151
- <RenameInput
152
- initialName={node.name}
153
- onSubmit={(value) => onRenameSubmit(node, value)}
154
- onCancel={onRenameCancel}
155
- />
156
- </span>
157
- ) : (
158
- <span className={styles.fileLabel}>
159
- <span className={cx(styles.fileIcon, styles.fileDisclosure)}>
160
- <Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} />
161
- </span>
162
- <span>{node.name}</span>
163
- </span>
164
- );
165
- return (
166
- <div className={styles.fileBranch}>
167
- {isRenaming ? (
168
- <div className={styles.fileDirRow} style={depthStyle}>
169
- {label}
170
- </div>
171
- ) : (
172
- <button
173
- className={styles.fileDirRow}
174
- style={depthStyle}
175
- onClick={() => onToggle(node.path)}
176
- onContextMenu={(event) => onContextMenu(event, node)}>
177
- {label}
178
- </button>
179
- )}
180
- {isExpanded ? (
181
- <div>
182
- {node.children.map((child) => (
183
- <FileNode
184
- key={child.path}
185
- node={child}
186
- depth={depth + 1}
187
- expanded={expanded}
188
- selectedPath={selectedPath}
189
- renaming={renaming}
190
- onSelect={onSelect}
191
- onToggle={onToggle}
192
- onContextMenu={onContextMenu}
193
- onRenameSubmit={onRenameSubmit}
194
- onRenameCancel={onRenameCancel}
195
- />
196
- ))}
197
- </div>
198
- ) : null}
199
- </div>
200
- );
201
- }
202
- if (isRenaming) {
203
- return (
204
- <div className={styles.fileRow} style={depthStyle}>
205
- <span className={styles.fileLabel}>
206
- <span className={styles.fileIcon}>
207
- <Icon name={fileIconName(node.path)} />
208
- </span>
209
- <RenameInput
210
- initialName={basename(node.path)}
211
- onSubmit={(value) => onRenameSubmit(node, value)}
212
- onCancel={onRenameCancel}
213
- />
214
- </span>
215
- </div>
216
- );
217
- }
218
- return (
219
- <button
220
- className={cx(styles.fileRow, selectedPath === node.path && styles.fileRowSelected)}
221
- style={depthStyle}
222
- onClick={() => onSelect(node.path)}
223
- onContextMenu={(event) => onContextMenu(event, node)}>
224
- <span className={styles.fileLabel}>
225
- <span className={styles.fileIcon}>
226
- <Icon name={fileIconName(node.path)} />
227
- </span>
228
- <span>{basename(node.path)}</span>
229
- </span>
230
- </button>
231
- );
232
- }
233
- // Inline rename field: focuses + selects on mount, commits on Enter or blur,
234
- // abandons on Escape. A done-guard keeps the blur that follows an
235
- // Enter/Escape from firing a second submit/cancel.
236
- function RenameInput({ initialName, onSubmit, onCancel }) {
237
- const [value, setValue] = useState(initialName);
238
- const inputRef = useRef(null);
239
- const doneRef = useRef(false);
240
- useEffect(() => {
241
- const el = inputRef.current;
242
- if (!el) return;
243
- el.focus();
244
- el.select();
245
- }, []);
246
- function commit() {
247
- if (doneRef.current) return;
248
- doneRef.current = true;
249
- onSubmit(value);
250
- }
251
- function abandon() {
252
- if (doneRef.current) return;
253
- doneRef.current = true;
254
- onCancel();
255
- }
256
- return (
257
- <input
258
- ref={inputRef}
259
- className={styles.fileRenameInput}
260
- value={value}
261
- onClick={(event) => event.stopPropagation()}
262
- onChange={(event) => setValue(event.target.value)}
263
- onBlur={commit}
264
- onKeyDown={(event) => {
265
- if (event.key === 'Enter') {
266
- event.preventDefault();
267
- commit();
268
- } else if (event.key === 'Escape') {
269
- event.preventDefault();
270
- abandon();
271
- }
272
- }}
273
- />
274
- );
275
- }
276
- // Re-path a set of expanded directory paths after a folder rename so the
277
- // renamed folder (and any expanded descendants) stay open in place.
278
- function repathExpanded(set, oldDir, newDir) {
279
- const next = new Set();
280
- for (const path of set) {
281
- if (path === oldDir) next.add(newDir);
282
- else if (path.startsWith(`${oldDir}/`)) next.add(`${newDir}${path.slice(oldDir.length)}`);
283
- else next.add(path);
284
- }
285
- return next;
286
- }
287
- // Per-file-type icon, keyed by extension. Sprites (.pxart) -> layer-group,
288
- // scenes -> globe, behaviors (.jsx) -> code, anything else -> a generic file
289
- // icon.
290
- function fileIconName(path) {
291
- if (path.endsWith('.pxart')) return 'layer-group';
292
- if (path.endsWith('.scene')) return 'globe';
293
- if (path.endsWith('.jsx')) return 'code';
294
- return 'file';
295
- }
296
- function buildFileTree(paths) {
297
- const root = {
298
- type: 'directory',
299
- name: '',
300
- path: '',
301
- children: [],
302
- childMap: new Map(),
303
- };
304
- for (const path of paths) {
305
- const parts = path.split('/');
306
- let parent = root;
307
- for (let index = 0; index < parts.length; index++) {
308
- const name = parts[index];
309
- const nodePath = parts.slice(0, index + 1).join('/');
310
- const isFile = index === parts.length - 1;
311
- if (!parent.childMap?.has(name)) {
312
- const node = isFile
313
- ? { type: 'file', name, path: nodePath }
314
- : { type: 'directory', name, path: nodePath, children: [], childMap: new Map() };
315
- parent.childMap?.set(name, node);
316
- parent.children.push(node);
317
- }
318
- const child = parent.childMap?.get(name);
319
- if (!child || child.type !== 'directory') break;
320
- parent = child;
321
- }
322
- }
323
- pruneChildMaps(root);
324
- sortRootChildren(root);
325
- return root;
326
- }
327
- function pruneChildMaps(node) {
328
- if (node.type !== 'directory') return;
329
- for (const child of node.children) pruneChildMaps(child);
330
- delete node.childMap;
331
- }
332
- // Top-level folders list in a fixed order: drawings, then scenes, then
333
- // behaviors, then anything else alphabetically.
334
- function sortRootChildren(root) {
335
- const rank = (name) => {
336
- if (name === 'drawings') return 0;
337
- if (name === 'scenes') return 1;
338
- if (name === 'behaviors') return 2;
339
- return 3;
340
- };
341
- root.children.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
342
- }
343
- function collectDirectoryPaths(node) {
344
- if (node.type !== 'directory') return [];
345
- return [
346
- ...(node.path ? [node.path] : []),
347
- ...node.children.flatMap((child) => collectDirectoryPaths(child)),
348
- ];
349
- }