@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.
@@ -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
- interface FileTreeProps {
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
- interface TreeNode {
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
- 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"
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
- children.map((child) =>
135
- child.isFile && child.children.size === 0 ? (
136
- <TreeFile
137
- key={child.fullPath}
138
- node={child}
139
- depth={depth + 1}
140
- activeFile={activeFile}
141
- onSelectFile={onSelectFile}
142
- />
143
- ) : child.children.size > 0 ? (
144
- <TreeFolder
145
- key={child.fullPath}
146
- node={child}
147
- depth={depth + 1}
148
- activeFile={activeFile}
149
- onSelectFile={onSelectFile}
150
- defaultOpen={isActiveInSubtree(child, activeFile)}
151
- />
152
- ) : (
153
- <TreeFile
154
- key={child.fullPath}
155
- node={child}
156
- depth={depth + 1}
157
- activeFile={activeFile}
158
- onSelectFile={onSelectFile}
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
- function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
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({ files, activeFile, onSelectFile }: FileTreeProps) {
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
- <div className="flex-1 overflow-y-auto py-1">
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
  });