@hyperframes/studio 0.1.15 → 0.2.0
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/README.md +2 -2
- package/dist/assets/index-Bkp9HQbo.css +1 -0
- package/dist/assets/index-DfhSlTti.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +231 -4
- package/src/components/editor/FileTree.tsx +759 -49
- package/src/components/sidebar/AssetsTab.tsx +180 -38
- package/src/components/sidebar/LeftSidebar.tsx +28 -3
- package/dist/assets/index-CLmYRLY-.css +0 -1
- package/dist/assets/index-CRvFpc0E.js +0 -84
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useState, useCallback, useMemo } from "react";
|
|
1
|
+
import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
2
2
|
import {
|
|
3
3
|
FileHtml,
|
|
4
4
|
FileCss,
|
|
@@ -17,18 +17,65 @@ import {
|
|
|
17
17
|
Waveform,
|
|
18
18
|
TextAa,
|
|
19
19
|
Image as PhImage,
|
|
20
|
+
PencilSimple,
|
|
21
|
+
Copy,
|
|
22
|
+
Trash,
|
|
23
|
+
Plus,
|
|
24
|
+
FolderSimplePlus,
|
|
25
|
+
FilePlus,
|
|
26
|
+
FolderSimple,
|
|
20
27
|
} from "@phosphor-icons/react";
|
|
21
28
|
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
// ── Types ──
|
|
31
|
+
|
|
32
|
+
export interface FileTreeProps {
|
|
24
33
|
files: string[];
|
|
25
34
|
activeFile: string | null;
|
|
26
35
|
onSelectFile: (path: string) => void;
|
|
36
|
+
onCreateFile?: (path: string) => void;
|
|
37
|
+
onCreateFolder?: (path: string) => void;
|
|
38
|
+
onDeleteFile?: (path: string) => void;
|
|
39
|
+
onRenameFile?: (oldPath: string, newPath: string) => void;
|
|
40
|
+
onDuplicateFile?: (path: string) => void;
|
|
41
|
+
onMoveFile?: (oldPath: string, newPath: string) => void;
|
|
42
|
+
onImportFiles?: (files: FileList, dir?: string) => void;
|
|
27
43
|
}
|
|
28
44
|
|
|
45
|
+
interface TreeNode {
|
|
46
|
+
name: string;
|
|
47
|
+
fullPath: string;
|
|
48
|
+
children: Map<string, TreeNode>;
|
|
49
|
+
isFile: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ContextMenuState {
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
targetPath: string;
|
|
56
|
+
targetIsFolder: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface InlineInputState {
|
|
60
|
+
/** Parent folder path (empty string for root) */
|
|
61
|
+
parentPath: string;
|
|
62
|
+
/** "file" or "folder" creation, or "rename" */
|
|
63
|
+
mode: "new-file" | "new-folder" | "rename";
|
|
64
|
+
/** For rename mode, the original full path */
|
|
65
|
+
originalPath?: string;
|
|
66
|
+
/** For rename mode, the original name */
|
|
67
|
+
originalName?: string;
|
|
68
|
+
onCommit?: (name: string) => void;
|
|
69
|
+
onCancel?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Constants ──
|
|
73
|
+
|
|
29
74
|
const SZ = 14;
|
|
30
75
|
const W = "duotone" as const;
|
|
31
76
|
|
|
77
|
+
// ── FileIcon ──
|
|
78
|
+
|
|
32
79
|
function FileIcon({ path }: { path: string }) {
|
|
33
80
|
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
34
81
|
const c = "flex-shrink-0";
|
|
@@ -59,12 +106,7 @@ function FileIcon({ path }: { path: string }) {
|
|
|
59
106
|
return <File size={SZ} weight={W} color="#6B7280" className={c} />;
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
name: string;
|
|
64
|
-
fullPath: string;
|
|
65
|
-
children: Map<string, TreeNode>;
|
|
66
|
-
isFile: boolean;
|
|
67
|
-
}
|
|
109
|
+
// ── Tree Helpers ──
|
|
68
110
|
|
|
69
111
|
function buildTree(files: string[]): TreeNode {
|
|
70
112
|
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
|
|
@@ -102,83 +144,478 @@ function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
|
|
|
102
144
|
});
|
|
103
145
|
}
|
|
104
146
|
|
|
147
|
+
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
|
148
|
+
if (!activeFile) return false;
|
|
149
|
+
if (node.fullPath === activeFile) return true;
|
|
150
|
+
for (const child of node.children.values()) {
|
|
151
|
+
if (isActiveInSubtree(child, activeFile)) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Context Menu Component ──
|
|
157
|
+
|
|
158
|
+
function ContextMenu({
|
|
159
|
+
state,
|
|
160
|
+
onClose,
|
|
161
|
+
onNewFile,
|
|
162
|
+
onNewFolder,
|
|
163
|
+
onRename,
|
|
164
|
+
onDuplicate,
|
|
165
|
+
onDelete,
|
|
166
|
+
}: {
|
|
167
|
+
state: ContextMenuState;
|
|
168
|
+
onClose: () => void;
|
|
169
|
+
onNewFile: (parentPath: string) => void;
|
|
170
|
+
onNewFolder: (parentPath: string) => void;
|
|
171
|
+
onRename: (path: string) => void;
|
|
172
|
+
onDuplicate: (path: string) => void;
|
|
173
|
+
onDelete: (path: string) => void;
|
|
174
|
+
}) {
|
|
175
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
176
|
+
|
|
177
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
180
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
181
|
+
onClose();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
185
|
+
if (e.key === "Escape") onClose();
|
|
186
|
+
};
|
|
187
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
188
|
+
document.addEventListener("keydown", handleEscape);
|
|
189
|
+
return () => {
|
|
190
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
191
|
+
document.removeEventListener("keydown", handleEscape);
|
|
192
|
+
};
|
|
193
|
+
}, [onClose]);
|
|
194
|
+
|
|
195
|
+
// Adjust position so menu doesn't overflow viewport
|
|
196
|
+
const adjustedX = Math.min(state.x, window.innerWidth - 180);
|
|
197
|
+
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
|
198
|
+
|
|
199
|
+
const parentPath = state.targetIsFolder
|
|
200
|
+
? state.targetPath
|
|
201
|
+
: state.targetPath.includes("/")
|
|
202
|
+
? state.targetPath.slice(0, state.targetPath.lastIndexOf("/"))
|
|
203
|
+
: "";
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={menuRef}
|
|
208
|
+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
|
209
|
+
style={{ left: adjustedX, top: adjustedY }}
|
|
210
|
+
>
|
|
211
|
+
{state.targetIsFolder && (
|
|
212
|
+
<>
|
|
213
|
+
<button
|
|
214
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
215
|
+
onClick={() => {
|
|
216
|
+
onNewFile(state.targetPath);
|
|
217
|
+
onClose();
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
221
|
+
New File
|
|
222
|
+
</button>
|
|
223
|
+
<button
|
|
224
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
225
|
+
onClick={() => {
|
|
226
|
+
onNewFolder(state.targetPath);
|
|
227
|
+
onClose();
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
231
|
+
New Folder
|
|
232
|
+
</button>
|
|
233
|
+
<div className="border-t border-neutral-700 my-1" />
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
{!state.targetIsFolder && (
|
|
237
|
+
<>
|
|
238
|
+
<button
|
|
239
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
240
|
+
onClick={() => {
|
|
241
|
+
onNewFile(parentPath);
|
|
242
|
+
onClose();
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
246
|
+
New File
|
|
247
|
+
</button>
|
|
248
|
+
<div className="border-t border-neutral-700 my-1" />
|
|
249
|
+
</>
|
|
250
|
+
)}
|
|
251
|
+
<button
|
|
252
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
253
|
+
onClick={() => {
|
|
254
|
+
onRename(state.targetPath);
|
|
255
|
+
onClose();
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<PencilSimple size={12} weight="duotone" className="text-neutral-500" />
|
|
259
|
+
Rename
|
|
260
|
+
</button>
|
|
261
|
+
{!state.targetIsFolder && (
|
|
262
|
+
<button
|
|
263
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
264
|
+
onClick={() => {
|
|
265
|
+
onDuplicate(state.targetPath);
|
|
266
|
+
onClose();
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
<Copy size={12} weight="duotone" className="text-neutral-500" />
|
|
270
|
+
Duplicate
|
|
271
|
+
</button>
|
|
272
|
+
)}
|
|
273
|
+
<div className="border-t border-neutral-700 my-1" />
|
|
274
|
+
<button
|
|
275
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 cursor-pointer text-left"
|
|
276
|
+
onClick={() => {
|
|
277
|
+
onDelete(state.targetPath);
|
|
278
|
+
onClose();
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<Trash size={12} weight="duotone" />
|
|
282
|
+
Delete
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Inline Input (for new file/folder/rename) ──
|
|
289
|
+
|
|
290
|
+
function InlineInput({
|
|
291
|
+
defaultValue,
|
|
292
|
+
depth,
|
|
293
|
+
isFolder,
|
|
294
|
+
onCommit,
|
|
295
|
+
onCancel,
|
|
296
|
+
}: {
|
|
297
|
+
defaultValue: string;
|
|
298
|
+
depth: number;
|
|
299
|
+
isFolder: boolean;
|
|
300
|
+
onCommit: (value: string) => void;
|
|
301
|
+
onCancel: () => void;
|
|
302
|
+
}) {
|
|
303
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
304
|
+
const committedRef = useRef(false);
|
|
305
|
+
const [value, setValue] = useState(defaultValue);
|
|
306
|
+
|
|
307
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const el = inputRef.current;
|
|
310
|
+
if (!el) return;
|
|
311
|
+
el.focus();
|
|
312
|
+
// Select just the filename (not extension) for rename
|
|
313
|
+
if (defaultValue && defaultValue.includes(".")) {
|
|
314
|
+
const dotIdx = defaultValue.lastIndexOf(".");
|
|
315
|
+
el.setSelectionRange(0, dotIdx);
|
|
316
|
+
} else {
|
|
317
|
+
el.select();
|
|
318
|
+
}
|
|
319
|
+
}, [defaultValue]);
|
|
320
|
+
|
|
321
|
+
const commit = (name: string) => {
|
|
322
|
+
if (committedRef.current) return;
|
|
323
|
+
committedRef.current = true;
|
|
324
|
+
onCommit(name);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
328
|
+
if (e.key === "Enter") {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
const trimmed = value.trim();
|
|
331
|
+
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
|
|
332
|
+
else onCancel();
|
|
333
|
+
} else if (e.key === "Escape") {
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
onCancel();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const handleBlur = () => {
|
|
340
|
+
const trimmed = value.trim();
|
|
341
|
+
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
|
|
342
|
+
commit(trimmed);
|
|
343
|
+
else onCancel();
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div
|
|
348
|
+
className="flex items-center gap-2 py-0.5 min-h-7"
|
|
349
|
+
style={{ paddingLeft: `${8 + depth * 12 + (isFolder ? 0 : 14)}px` }}
|
|
350
|
+
>
|
|
351
|
+
{isFolder ? (
|
|
352
|
+
<FolderSimple size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />
|
|
353
|
+
) : (
|
|
354
|
+
<FileIcon path={value} />
|
|
355
|
+
)}
|
|
356
|
+
<input
|
|
357
|
+
ref={inputRef}
|
|
358
|
+
value={value}
|
|
359
|
+
onChange={(e) => setValue(e.target.value)}
|
|
360
|
+
onKeyDown={handleKeyDown}
|
|
361
|
+
onBlur={handleBlur}
|
|
362
|
+
className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]"
|
|
363
|
+
spellCheck={false}
|
|
364
|
+
/>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Delete Confirmation ──
|
|
370
|
+
|
|
371
|
+
function DeleteConfirm({
|
|
372
|
+
name,
|
|
373
|
+
onConfirm,
|
|
374
|
+
onCancel,
|
|
375
|
+
}: {
|
|
376
|
+
name: string;
|
|
377
|
+
onConfirm: () => void;
|
|
378
|
+
onCancel: () => void;
|
|
379
|
+
}) {
|
|
380
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
381
|
+
|
|
382
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
385
|
+
if (e.key === "Escape") onCancel();
|
|
386
|
+
};
|
|
387
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
388
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
|
|
389
|
+
};
|
|
390
|
+
document.addEventListener("keydown", handleEscape);
|
|
391
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
392
|
+
return () => {
|
|
393
|
+
document.removeEventListener("keydown", handleEscape);
|
|
394
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
395
|
+
};
|
|
396
|
+
}, [onCancel]);
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<div
|
|
400
|
+
ref={ref}
|
|
401
|
+
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
|
|
402
|
+
>
|
|
403
|
+
<p className="text-neutral-300 mb-2">
|
|
404
|
+
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
|
405
|
+
</p>
|
|
406
|
+
<div className="flex gap-1.5">
|
|
407
|
+
<button
|
|
408
|
+
onClick={onCancel}
|
|
409
|
+
className="flex-1 px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 transition-colors"
|
|
410
|
+
>
|
|
411
|
+
Cancel
|
|
412
|
+
</button>
|
|
413
|
+
<button
|
|
414
|
+
onClick={onConfirm}
|
|
415
|
+
className="flex-1 px-2 py-1 rounded bg-red-900/60 text-red-300 hover:bg-red-800/60 transition-colors"
|
|
416
|
+
>
|
|
417
|
+
Delete
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── TreeFolder ──
|
|
425
|
+
|
|
105
426
|
function TreeFolder({
|
|
106
427
|
node,
|
|
107
428
|
depth,
|
|
108
429
|
activeFile,
|
|
109
430
|
onSelectFile,
|
|
110
431
|
defaultOpen,
|
|
432
|
+
onContextMenu,
|
|
433
|
+
inlineInput,
|
|
434
|
+
onDragStart,
|
|
435
|
+
onDragOver,
|
|
436
|
+
onDrop,
|
|
437
|
+
onDragLeave,
|
|
438
|
+
dragOverFolder,
|
|
111
439
|
}: {
|
|
112
440
|
node: TreeNode;
|
|
113
441
|
depth: number;
|
|
114
442
|
activeFile: string | null;
|
|
115
443
|
onSelectFile: (path: string) => void;
|
|
116
444
|
defaultOpen: boolean;
|
|
445
|
+
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
|
446
|
+
inlineInput: InlineInputState | null;
|
|
447
|
+
onDragStart: (e: React.DragEvent, path: string) => void;
|
|
448
|
+
onDragOver: (e: React.DragEvent, folderPath: string) => void;
|
|
449
|
+
onDrop: (e: React.DragEvent, folderPath: string) => void;
|
|
450
|
+
onDragLeave: () => void;
|
|
451
|
+
dragOverFolder: string | null;
|
|
117
452
|
}) {
|
|
118
453
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
119
454
|
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
|
120
|
-
const children = sortChildren(node.children);
|
|
455
|
+
const children = useMemo(() => sortChildren(node.children), [node.children]);
|
|
121
456
|
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
|
457
|
+
const isDragOver = dragOverFolder === node.fullPath;
|
|
458
|
+
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
|
459
|
+
|
|
460
|
+
if (isRenaming) {
|
|
461
|
+
return (
|
|
462
|
+
<InlineInput
|
|
463
|
+
defaultValue={inlineInput.originalName ?? node.name}
|
|
464
|
+
depth={depth}
|
|
465
|
+
isFolder={true}
|
|
466
|
+
onCommit={(name) => {
|
|
467
|
+
inlineInput?.onCommit?.(name);
|
|
468
|
+
}}
|
|
469
|
+
onCancel={() => {
|
|
470
|
+
inlineInput?.onCancel?.();
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
122
475
|
|
|
123
476
|
return (
|
|
124
477
|
<>
|
|
125
478
|
<button
|
|
479
|
+
draggable
|
|
480
|
+
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
|
126
481
|
onClick={toggle}
|
|
127
|
-
|
|
482
|
+
onContextMenu={(e) => {
|
|
483
|
+
e.preventDefault();
|
|
484
|
+
onContextMenu(e, node.fullPath, true);
|
|
485
|
+
}}
|
|
486
|
+
onDragOver={(e) => {
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
e.stopPropagation();
|
|
489
|
+
onDragOver(e, node.fullPath);
|
|
490
|
+
}}
|
|
491
|
+
onDrop={(e) => {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
e.stopPropagation();
|
|
494
|
+
onDrop(e, node.fullPath);
|
|
495
|
+
}}
|
|
496
|
+
onDragLeave={onDragLeave}
|
|
497
|
+
className={`w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors ${
|
|
498
|
+
isDragOver ? "bg-[#3CE6AC]/10 outline outline-1 outline-[#3CE6AC]/40" : ""
|
|
499
|
+
}`}
|
|
128
500
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
|
129
501
|
>
|
|
130
502
|
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
|
|
131
503
|
<span className="truncate font-medium">{node.name}</span>
|
|
132
504
|
</button>
|
|
133
|
-
{isOpen &&
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
505
|
+
{isOpen && (
|
|
506
|
+
<>
|
|
507
|
+
{/* Inline input for new file/folder inside this folder */}
|
|
508
|
+
{inlineInput &&
|
|
509
|
+
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
|
510
|
+
inlineInput.parentPath === node.fullPath && (
|
|
511
|
+
<InlineInput
|
|
512
|
+
defaultValue=""
|
|
513
|
+
depth={depth + 1}
|
|
514
|
+
isFolder={inlineInput.mode === "new-folder"}
|
|
515
|
+
onCommit={(name) => {
|
|
516
|
+
// onCommit is handled by the parent FileTree component
|
|
517
|
+
// via the inlineInputCommit callback
|
|
518
|
+
inlineInput?.onCommit?.(name);
|
|
519
|
+
}}
|
|
520
|
+
onCancel={() => {
|
|
521
|
+
inlineInput?.onCancel?.();
|
|
522
|
+
}}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
525
|
+
{children.map((child) =>
|
|
526
|
+
child.isFile && child.children.size === 0 ? (
|
|
527
|
+
<TreeFile
|
|
528
|
+
key={child.fullPath}
|
|
529
|
+
node={child}
|
|
530
|
+
depth={depth + 1}
|
|
531
|
+
activeFile={activeFile}
|
|
532
|
+
onSelectFile={onSelectFile}
|
|
533
|
+
onContextMenu={onContextMenu}
|
|
534
|
+
inlineInput={inlineInput}
|
|
535
|
+
onDragStart={onDragStart}
|
|
536
|
+
/>
|
|
537
|
+
) : child.children.size > 0 ? (
|
|
538
|
+
<TreeFolder
|
|
539
|
+
key={child.fullPath}
|
|
540
|
+
node={child}
|
|
541
|
+
depth={depth + 1}
|
|
542
|
+
activeFile={activeFile}
|
|
543
|
+
onSelectFile={onSelectFile}
|
|
544
|
+
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
545
|
+
onContextMenu={onContextMenu}
|
|
546
|
+
inlineInput={inlineInput}
|
|
547
|
+
onDragStart={onDragStart}
|
|
548
|
+
onDragOver={onDragOver}
|
|
549
|
+
onDrop={onDrop}
|
|
550
|
+
onDragLeave={onDragLeave}
|
|
551
|
+
dragOverFolder={dragOverFolder}
|
|
552
|
+
/>
|
|
553
|
+
) : (
|
|
554
|
+
<TreeFile
|
|
555
|
+
key={child.fullPath}
|
|
556
|
+
node={child}
|
|
557
|
+
depth={depth + 1}
|
|
558
|
+
activeFile={activeFile}
|
|
559
|
+
onSelectFile={onSelectFile}
|
|
560
|
+
onContextMenu={onContextMenu}
|
|
561
|
+
inlineInput={inlineInput}
|
|
562
|
+
onDragStart={onDragStart}
|
|
563
|
+
/>
|
|
564
|
+
),
|
|
565
|
+
)}
|
|
566
|
+
</>
|
|
567
|
+
)}
|
|
162
568
|
</>
|
|
163
569
|
);
|
|
164
570
|
}
|
|
165
571
|
|
|
572
|
+
// ── TreeFile ──
|
|
573
|
+
|
|
166
574
|
function TreeFile({
|
|
167
575
|
node,
|
|
168
576
|
depth,
|
|
169
577
|
activeFile,
|
|
170
578
|
onSelectFile,
|
|
579
|
+
onContextMenu,
|
|
580
|
+
inlineInput,
|
|
581
|
+
onDragStart,
|
|
171
582
|
}: {
|
|
172
583
|
node: TreeNode;
|
|
173
584
|
depth: number;
|
|
174
585
|
activeFile: string | null;
|
|
175
586
|
onSelectFile: (path: string) => void;
|
|
587
|
+
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
|
588
|
+
inlineInput: InlineInputState | null;
|
|
589
|
+
onDragStart: (e: React.DragEvent, path: string) => void;
|
|
176
590
|
}) {
|
|
177
591
|
const isActive = node.fullPath === activeFile;
|
|
592
|
+
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
|
593
|
+
|
|
594
|
+
if (isRenaming) {
|
|
595
|
+
return (
|
|
596
|
+
<InlineInput
|
|
597
|
+
defaultValue={inlineInput.originalName ?? node.name}
|
|
598
|
+
depth={depth}
|
|
599
|
+
isFolder={false}
|
|
600
|
+
onCommit={(name) => {
|
|
601
|
+
inlineInput?.onCommit?.(name);
|
|
602
|
+
}}
|
|
603
|
+
onCancel={() => {
|
|
604
|
+
inlineInput?.onCancel?.();
|
|
605
|
+
}}
|
|
606
|
+
/>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
178
609
|
|
|
179
610
|
return (
|
|
180
611
|
<button
|
|
612
|
+
draggable
|
|
613
|
+
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
|
181
614
|
onClick={() => onSelectFile(node.fullPath)}
|
|
615
|
+
onContextMenu={(e) => {
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
onContextMenu(e, node.fullPath, false);
|
|
618
|
+
}}
|
|
182
619
|
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
|
|
183
620
|
isActive
|
|
184
621
|
? "bg-neutral-800/60 text-neutral-200"
|
|
@@ -192,22 +629,257 @@ function TreeFile({
|
|
|
192
629
|
);
|
|
193
630
|
}
|
|
194
631
|
|
|
195
|
-
|
|
196
|
-
if (!activeFile) return false;
|
|
197
|
-
if (node.fullPath === activeFile) return true;
|
|
198
|
-
for (const child of node.children.values()) {
|
|
199
|
-
if (isActiveInSubtree(child, activeFile)) return true;
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
632
|
+
// ── Main FileTree Component ──
|
|
203
633
|
|
|
204
|
-
export const FileTree = memo(function FileTree({
|
|
634
|
+
export const FileTree = memo(function FileTree({
|
|
635
|
+
files,
|
|
636
|
+
activeFile,
|
|
637
|
+
onSelectFile,
|
|
638
|
+
onCreateFile,
|
|
639
|
+
onCreateFolder,
|
|
640
|
+
onDeleteFile,
|
|
641
|
+
onRenameFile,
|
|
642
|
+
onDuplicateFile,
|
|
643
|
+
onMoveFile,
|
|
644
|
+
onImportFiles,
|
|
645
|
+
}: FileTreeProps) {
|
|
205
646
|
const tree = useMemo(() => buildTree(files), [files]);
|
|
206
647
|
const children = useMemo(() => sortChildren(tree.children), [tree]);
|
|
207
648
|
|
|
649
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
650
|
+
const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
|
|
651
|
+
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
652
|
+
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
|
653
|
+
const dragSourceRef = useRef<string | null>(null);
|
|
654
|
+
|
|
655
|
+
const hasFileOps = !!(
|
|
656
|
+
onCreateFile ||
|
|
657
|
+
onCreateFolder ||
|
|
658
|
+
onDeleteFile ||
|
|
659
|
+
onRenameFile ||
|
|
660
|
+
onDuplicateFile
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// ── Context Menu handlers ──
|
|
664
|
+
|
|
665
|
+
const handleContextMenu = useCallback(
|
|
666
|
+
(e: React.MouseEvent, path: string, isFolder: boolean) => {
|
|
667
|
+
if (!hasFileOps) return;
|
|
668
|
+
e.preventDefault();
|
|
669
|
+
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: path, targetIsFolder: isFolder });
|
|
670
|
+
},
|
|
671
|
+
[hasFileOps],
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const handleCloseContextMenu = useCallback(() => setContextMenu(null), []);
|
|
675
|
+
|
|
676
|
+
// ── New File ──
|
|
677
|
+
|
|
678
|
+
const handleNewFile = useCallback(
|
|
679
|
+
(parentPath: string) => {
|
|
680
|
+
setInlineInput({
|
|
681
|
+
parentPath,
|
|
682
|
+
mode: "new-file",
|
|
683
|
+
onCommit: (name: string) => {
|
|
684
|
+
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
|
685
|
+
onCreateFile?.(fullPath);
|
|
686
|
+
setInlineInput(null);
|
|
687
|
+
},
|
|
688
|
+
onCancel: () => setInlineInput(null),
|
|
689
|
+
});
|
|
690
|
+
},
|
|
691
|
+
[onCreateFile],
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// ── New Folder ──
|
|
695
|
+
|
|
696
|
+
const handleNewFolder = useCallback(
|
|
697
|
+
(parentPath: string) => {
|
|
698
|
+
setInlineInput({
|
|
699
|
+
parentPath,
|
|
700
|
+
mode: "new-folder",
|
|
701
|
+
onCommit: (name: string) => {
|
|
702
|
+
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
|
703
|
+
onCreateFolder?.(fullPath);
|
|
704
|
+
setInlineInput(null);
|
|
705
|
+
},
|
|
706
|
+
onCancel: () => setInlineInput(null),
|
|
707
|
+
});
|
|
708
|
+
},
|
|
709
|
+
[onCreateFolder],
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// ── Rename ──
|
|
713
|
+
|
|
714
|
+
const handleRename = useCallback(
|
|
715
|
+
(path: string) => {
|
|
716
|
+
const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path;
|
|
717
|
+
const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
718
|
+
setInlineInput({
|
|
719
|
+
parentPath,
|
|
720
|
+
mode: "rename",
|
|
721
|
+
originalPath: path,
|
|
722
|
+
originalName: name,
|
|
723
|
+
onCommit: (newName: string) => {
|
|
724
|
+
if (newName !== name) {
|
|
725
|
+
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
|
726
|
+
onRenameFile?.(path, newPath);
|
|
727
|
+
}
|
|
728
|
+
setInlineInput(null);
|
|
729
|
+
},
|
|
730
|
+
onCancel: () => setInlineInput(null),
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
[onRenameFile],
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// ── Duplicate ──
|
|
737
|
+
|
|
738
|
+
const handleDuplicate = useCallback(
|
|
739
|
+
(path: string) => {
|
|
740
|
+
onDuplicateFile?.(path);
|
|
741
|
+
},
|
|
742
|
+
[onDuplicateFile],
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// ── Delete ──
|
|
746
|
+
|
|
747
|
+
const handleDelete = useCallback((path: string) => {
|
|
748
|
+
setDeleteTarget(path);
|
|
749
|
+
}, []);
|
|
750
|
+
|
|
751
|
+
// Since DeleteConfirm is rendered inside TreeFile, we need callbacks on that component.
|
|
752
|
+
// Instead, let's use a portal-style approach: render the confirm at the FileTree level.
|
|
753
|
+
const handleDeleteConfirm = useCallback(() => {
|
|
754
|
+
if (deleteTarget) {
|
|
755
|
+
onDeleteFile?.(deleteTarget);
|
|
756
|
+
setDeleteTarget(null);
|
|
757
|
+
}
|
|
758
|
+
}, [deleteTarget, onDeleteFile]);
|
|
759
|
+
|
|
760
|
+
const handleDeleteCancel = useCallback(() => {
|
|
761
|
+
setDeleteTarget(null);
|
|
762
|
+
}, []);
|
|
763
|
+
|
|
764
|
+
// ── Drag and Drop ──
|
|
765
|
+
|
|
766
|
+
const handleDragStart = useCallback((e: React.DragEvent, path: string) => {
|
|
767
|
+
dragSourceRef.current = path;
|
|
768
|
+
e.dataTransfer.effectAllowed = "move";
|
|
769
|
+
e.dataTransfer.setData("text/plain", path);
|
|
770
|
+
}, []);
|
|
771
|
+
|
|
772
|
+
const handleDragOver = useCallback((_e: React.DragEvent, folderPath: string) => {
|
|
773
|
+
setDragOverFolder(folderPath);
|
|
774
|
+
}, []);
|
|
775
|
+
|
|
776
|
+
const handleDrop = useCallback(
|
|
777
|
+
(e: React.DragEvent, folderPath: string) => {
|
|
778
|
+
// External files from desktop — import into the target folder
|
|
779
|
+
if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) {
|
|
780
|
+
e.preventDefault();
|
|
781
|
+
onImportFiles?.(e.dataTransfer.files, folderPath || undefined);
|
|
782
|
+
setDragOverFolder(null);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const sourcePath = dragSourceRef.current;
|
|
787
|
+
if (!sourcePath || !onMoveFile) {
|
|
788
|
+
setDragOverFolder(null);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
// Extract filename from source path
|
|
792
|
+
const fileName = sourcePath.includes("/")
|
|
793
|
+
? sourcePath.slice(sourcePath.lastIndexOf("/") + 1)
|
|
794
|
+
: sourcePath;
|
|
795
|
+
const newPath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
|
796
|
+
// Don't move to same location or into own subtree
|
|
797
|
+
if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) {
|
|
798
|
+
onMoveFile(sourcePath, newPath);
|
|
799
|
+
}
|
|
800
|
+
setDragOverFolder(null);
|
|
801
|
+
dragSourceRef.current = null;
|
|
802
|
+
},
|
|
803
|
+
[onMoveFile, onImportFiles],
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const handleDragLeave = useCallback(() => {
|
|
807
|
+
setDragOverFolder(null);
|
|
808
|
+
}, []);
|
|
809
|
+
|
|
810
|
+
// ── Root-level context menu (right-click on empty space) ──
|
|
811
|
+
|
|
812
|
+
const handleRootContextMenu = useCallback(
|
|
813
|
+
(e: React.MouseEvent) => {
|
|
814
|
+
if (!hasFileOps) return;
|
|
815
|
+
// Only trigger if clicking directly on the container, not on a file/folder button
|
|
816
|
+
if (e.target === e.currentTarget) {
|
|
817
|
+
e.preventDefault();
|
|
818
|
+
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetIsFolder: true });
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
[hasFileOps],
|
|
822
|
+
);
|
|
823
|
+
|
|
208
824
|
return (
|
|
209
825
|
<div className="flex flex-col h-full min-h-0">
|
|
210
|
-
|
|
826
|
+
{/* FILES header with action buttons */}
|
|
827
|
+
{hasFileOps && (
|
|
828
|
+
<div className="flex items-center justify-between px-2.5 py-1.5 border-b border-neutral-800/50 flex-shrink-0">
|
|
829
|
+
<span className="text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
|
|
830
|
+
Files
|
|
831
|
+
</span>
|
|
832
|
+
<div className="flex items-center gap-0.5">
|
|
833
|
+
<button
|
|
834
|
+
onClick={() => handleNewFile("")}
|
|
835
|
+
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
|
836
|
+
title="New File"
|
|
837
|
+
>
|
|
838
|
+
<Plus size={12} weight="bold" />
|
|
839
|
+
</button>
|
|
840
|
+
<button
|
|
841
|
+
onClick={() => handleNewFolder("")}
|
|
842
|
+
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
|
843
|
+
title="New Folder"
|
|
844
|
+
>
|
|
845
|
+
<FolderSimplePlus size={12} weight="duotone" />
|
|
846
|
+
</button>
|
|
847
|
+
</div>
|
|
848
|
+
</div>
|
|
849
|
+
)}
|
|
850
|
+
|
|
851
|
+
<div
|
|
852
|
+
className={`flex-1 overflow-y-auto py-1 transition-colors ${
|
|
853
|
+
dragOverFolder === ""
|
|
854
|
+
? "bg-[#3CE6AC]/5 outline outline-1 outline-[#3CE6AC]/30 -outline-offset-1"
|
|
855
|
+
: ""
|
|
856
|
+
}`}
|
|
857
|
+
onContextMenu={handleRootContextMenu}
|
|
858
|
+
onDragOver={(e) => {
|
|
859
|
+
e.preventDefault();
|
|
860
|
+
// Show root highlight when dragging over the background (not a child folder)
|
|
861
|
+
if (e.target === e.currentTarget) setDragOverFolder("");
|
|
862
|
+
}}
|
|
863
|
+
onDragLeave={(e) => {
|
|
864
|
+
if (e.target === e.currentTarget) setDragOverFolder(null);
|
|
865
|
+
}}
|
|
866
|
+
onDrop={(e) => {
|
|
867
|
+
e.preventDefault();
|
|
868
|
+
handleDrop(e, "");
|
|
869
|
+
}}
|
|
870
|
+
>
|
|
871
|
+
{/* Root-level inline input for new file/folder */}
|
|
872
|
+
{inlineInput &&
|
|
873
|
+
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
|
874
|
+
inlineInput.parentPath === "" && (
|
|
875
|
+
<InlineInput
|
|
876
|
+
defaultValue=""
|
|
877
|
+
depth={0}
|
|
878
|
+
isFolder={inlineInput.mode === "new-folder"}
|
|
879
|
+
onCommit={(name) => inlineInput.onCommit?.(name)}
|
|
880
|
+
onCancel={() => inlineInput.onCancel?.()}
|
|
881
|
+
/>
|
|
882
|
+
)}
|
|
211
883
|
{children.map((child) =>
|
|
212
884
|
child.isFile && child.children.size === 0 ? (
|
|
213
885
|
<TreeFile
|
|
@@ -216,6 +888,9 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
|
|
|
216
888
|
depth={0}
|
|
217
889
|
activeFile={activeFile}
|
|
218
890
|
onSelectFile={onSelectFile}
|
|
891
|
+
onContextMenu={handleContextMenu}
|
|
892
|
+
inlineInput={inlineInput}
|
|
893
|
+
onDragStart={handleDragStart}
|
|
219
894
|
/>
|
|
220
895
|
) : (
|
|
221
896
|
<TreeFolder
|
|
@@ -225,10 +900,45 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
|
|
|
225
900
|
activeFile={activeFile}
|
|
226
901
|
onSelectFile={onSelectFile}
|
|
227
902
|
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
903
|
+
onContextMenu={handleContextMenu}
|
|
904
|
+
inlineInput={inlineInput}
|
|
905
|
+
onDragStart={handleDragStart}
|
|
906
|
+
onDragOver={handleDragOver}
|
|
907
|
+
onDrop={handleDrop}
|
|
908
|
+
onDragLeave={handleDragLeave}
|
|
909
|
+
dragOverFolder={dragOverFolder}
|
|
228
910
|
/>
|
|
229
911
|
),
|
|
230
912
|
)}
|
|
231
913
|
</div>
|
|
914
|
+
|
|
915
|
+
{/* Delete confirmation overlay */}
|
|
916
|
+
{deleteTarget && (
|
|
917
|
+
<div className="border-t border-neutral-800/50 flex-shrink-0">
|
|
918
|
+
<DeleteConfirm
|
|
919
|
+
name={
|
|
920
|
+
deleteTarget.includes("/")
|
|
921
|
+
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
|
|
922
|
+
: deleteTarget
|
|
923
|
+
}
|
|
924
|
+
onConfirm={handleDeleteConfirm}
|
|
925
|
+
onCancel={handleDeleteCancel}
|
|
926
|
+
/>
|
|
927
|
+
</div>
|
|
928
|
+
)}
|
|
929
|
+
|
|
930
|
+
{/* Context menu */}
|
|
931
|
+
{contextMenu && (
|
|
932
|
+
<ContextMenu
|
|
933
|
+
state={contextMenu}
|
|
934
|
+
onClose={handleCloseContextMenu}
|
|
935
|
+
onNewFile={handleNewFile}
|
|
936
|
+
onNewFolder={handleNewFolder}
|
|
937
|
+
onRename={handleRename}
|
|
938
|
+
onDuplicate={handleDuplicate}
|
|
939
|
+
onDelete={handleDelete}
|
|
940
|
+
/>
|
|
941
|
+
)}
|
|
232
942
|
</div>
|
|
233
943
|
);
|
|
234
944
|
});
|