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.
- package/dist/agent-prompts.d.ts +4 -0
- package/dist/agent-prompts.js +20 -8
- package/dist/agent.js +301 -28
- package/dist/castle-host/host.js +59 -5
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +4 -0
- package/dist/ide.d.ts +1 -0
- package/dist/ide.js +250 -1
- package/dist/init.js +46 -4
- package/dist/save-deck.js +8 -1
- package/dist/serve.js +27 -1
- package/dist/shell/assets/index-DNWEQd4R.js +141 -0
- package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +13 -5
- package/kits/basic-2d/castle.json +15 -0
- package/kits/basic-2d/drawings/pig.pxart +22 -55
- package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
- package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
- package/kits/basic-2d/editors/pixelGeometry.js +95 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
- package/kits/basic-2d/editors/pxArtTools.js +109 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +11 -160
- package/kits/basic-2d/engine/ui.jsx +15 -0
- package/kits/basic-2d/engine/ui.module.css +44 -117
- package/kits/basic-2d/main.jsx +6 -10
- package/package.json +10 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
- package/kits/basic-2d/editors/App.jsx +0 -226
- package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
- package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
- package/kits/basic-2d/editors/codeTheme.js +0 -135
- package/kits/basic-2d/engine/playConsole.js +0 -66
- package/kits/basic-3d/.prettierrc +0 -8
- package/kits/basic-3d/CLAUDE.md +0 -162
- package/kits/basic-3d/behaviors/Camera.jsx +0 -56
- package/kits/basic-3d/behaviors/Collider.jsx +0 -78
- package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
- package/kits/basic-3d/behaviors/Model.jsx +0 -61
- package/kits/basic-3d/behaviors/Transform.jsx +0 -35
- package/kits/basic-3d/editors/App.jsx +0 -147
- package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
- package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
- package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
- package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
- package/kits/basic-3d/editors/SceneEditor.jsx +0 -1081
- package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
- package/kits/basic-3d/editors/editorHistory.js +0 -52
- package/kits/basic-3d/editors/viewportRig.js +0 -90
- package/kits/basic-3d/engine/ScenePlayer.jsx +0 -58
- package/kits/basic-3d/engine/SceneUI.jsx +0 -67
- package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
- package/kits/basic-3d/engine/autoInspector.jsx +0 -51
- package/kits/basic-3d/engine/files.js +0 -73
- package/kits/basic-3d/engine/scene.js +0 -502
- package/kits/basic-3d/engine/threeUtil.js +0 -260
- package/kits/basic-3d/engine/ui.jsx +0 -352
- package/kits/basic-3d/engine/ui.module.css +0 -944
- package/kits/basic-3d/eslint.config.js +0 -51
- package/kits/basic-3d/index.html +0 -11
- package/kits/basic-3d/main.jsx +0 -10
- package/kits/basic-3d/models/block.model +0 -14
- package/kits/basic-3d/package-lock.json +0 -2713
- package/kits/basic-3d/package.json +0 -41
- package/kits/basic-3d/pnpm-lock.yaml +0 -1769
- package/kits/basic-3d/scenes/main.scene +0 -76
- 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
|
-
}
|