@hyperframes/studio 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. package/dist/assets/index-DUqUmaoH.js +0 -117
@@ -1,31 +1,17 @@
1
- import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react";
1
+ import { memo, useState, useCallback, useMemo, useRef } from "react";
2
+ import { Plus, FolderSimplePlus } from "@phosphor-icons/react";
2
3
  import {
3
- FileHtml,
4
- FileCss,
5
- FileJs,
6
- FileJsx,
7
- FileTs,
8
- FileTsx,
9
- FileTxt,
10
- FileMd,
11
- FileSvg,
12
- FilePng,
13
- FileJpg,
14
- FileVideo,
15
- FileCode,
16
- File,
17
- Waveform,
18
- TextAa,
19
- Image as PhImage,
20
- PencilSimple,
21
- Copy,
22
- Trash,
23
- Plus,
24
- FolderSimplePlus,
25
- FilePlus,
26
- FolderSimple,
27
- } from "@phosphor-icons/react";
28
- import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
4
+ buildTree,
5
+ sortChildren,
6
+ isActiveInSubtree,
7
+ ContextMenu,
8
+ InlineInput,
9
+ DeleteConfirm,
10
+ TreeFile,
11
+ TreeFolder,
12
+ type ContextMenuState,
13
+ type InlineInputState,
14
+ } from "./FileTreeNodes";
29
15
 
30
16
  // ── Types ──
31
17
 
@@ -42,593 +28,6 @@ export interface FileTreeProps {
42
28
  onImportFiles?: (files: FileList, dir?: string) => void;
43
29
  }
44
30
 
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
-
74
- const SZ = 14;
75
- const W = "duotone" as const;
76
-
77
- // ── FileIcon ──
78
-
79
- function FileIcon({ path }: { path: string }) {
80
- const ext = path.split(".").pop()?.toLowerCase() ?? "";
81
- const c = "flex-shrink-0";
82
- if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
83
- if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
84
- if (ext === "js" || ext === "mjs" || ext === "cjs")
85
- return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
86
- if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
87
- if (ext === "ts" || ext === "mts")
88
- return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
89
- if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
90
- if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
91
- if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
92
- if (ext === "md" || ext === "mdx")
93
- return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
94
- if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
95
- if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
96
- if (ext === "jpg" || ext === "jpeg")
97
- return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
98
- if (ext === "webp" || ext === "gif" || ext === "ico")
99
- return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
100
- if (ext === "mp4" || ext === "webm" || ext === "mov")
101
- return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
102
- if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
103
- return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
104
- if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
105
- return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
106
- return <File size={SZ} weight={W} color="#6B7280" className={c} />;
107
- }
108
-
109
- // ── Tree Helpers ──
110
-
111
- function buildTree(files: string[]): TreeNode {
112
- const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
113
- for (const file of files) {
114
- const parts = file.split("/");
115
- let current = root;
116
- for (let i = 0; i < parts.length; i++) {
117
- const part = parts[i];
118
- const isLast = i === parts.length - 1;
119
- const fullPath = parts.slice(0, i + 1).join("/");
120
- if (!current.children.has(part)) {
121
- current.children.set(part, {
122
- name: part,
123
- fullPath,
124
- children: new Map(),
125
- isFile: isLast,
126
- });
127
- }
128
- current = current.children.get(part)!;
129
- if (isLast) current.isFile = true;
130
- }
131
- }
132
- return root;
133
- }
134
-
135
- function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
136
- return Array.from(children.values()).sort((a, b) => {
137
- // index.html always first
138
- if (a.name === "index.html") return -1;
139
- if (b.name === "index.html") return 1;
140
- // Directories before files
141
- if (!a.isFile && b.isFile) return -1;
142
- if (a.isFile && !b.isFile) return 1;
143
- return a.name.localeCompare(b.name);
144
- });
145
- }
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
-
426
- function TreeFolder({
427
- node,
428
- depth,
429
- activeFile,
430
- onSelectFile,
431
- defaultOpen,
432
- onContextMenu,
433
- inlineInput,
434
- onDragStart,
435
- onDragOver,
436
- onDrop,
437
- onDragLeave,
438
- dragOverFolder,
439
- }: {
440
- node: TreeNode;
441
- depth: number;
442
- activeFile: string | null;
443
- onSelectFile: (path: string) => void;
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;
452
- }) {
453
- const [isOpen, setIsOpen] = useState(defaultOpen);
454
- const toggle = useCallback(() => setIsOpen((v) => !v), []);
455
- const children = useMemo(() => sortChildren(node.children), [node.children]);
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
- }
475
-
476
- return (
477
- <>
478
- <button
479
- draggable
480
- onDragStart={(e) => onDragStart(e, node.fullPath)}
481
- onClick={toggle}
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
- }`}
500
- style={{ paddingLeft: `${8 + depth * 12}px` }}
501
- >
502
- <Chevron size={10} className="flex-shrink-0 text-neutral-600" />
503
- <span className="truncate font-medium">{node.name}</span>
504
- </button>
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
- )}
568
- </>
569
- );
570
- }
571
-
572
- // ── TreeFile ──
573
-
574
- function TreeFile({
575
- node,
576
- depth,
577
- activeFile,
578
- onSelectFile,
579
- onContextMenu,
580
- inlineInput,
581
- onDragStart,
582
- }: {
583
- node: TreeNode;
584
- depth: number;
585
- activeFile: string | null;
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;
590
- }) {
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
- }
609
-
610
- return (
611
- <button
612
- draggable
613
- onDragStart={(e) => onDragStart(e, node.fullPath)}
614
- onClick={() => onSelectFile(node.fullPath)}
615
- onContextMenu={(e) => {
616
- e.preventDefault();
617
- onContextMenu(e, node.fullPath, false);
618
- }}
619
- className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
620
- isActive
621
- ? "bg-neutral-800/60 text-neutral-200"
622
- : "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
623
- }`}
624
- style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
625
- >
626
- <FileIcon path={node.name} />
627
- <span className="truncate">{node.name}</span>
628
- </button>
629
- );
630
- }
631
-
632
31
  // ── Main FileTree Component ──
633
32
 
634
33
  export const FileTree = memo(function FileTree({
@@ -748,8 +147,6 @@ export const FileTree = memo(function FileTree({
748
147
  setDeleteTarget(path);
749
148
  }, []);
750
149
 
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
150
  const handleDeleteConfirm = useCallback(() => {
754
151
  if (deleteTarget) {
755
152
  onDeleteFile?.(deleteTarget);
@@ -775,7 +172,6 @@ export const FileTree = memo(function FileTree({
775
172
 
776
173
  const handleDrop = useCallback(
777
174
  (e: React.DragEvent, folderPath: string) => {
778
- // External files from desktop — import into the target folder
779
175
  if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) {
780
176
  e.preventDefault();
781
177
  onImportFiles?.(e.dataTransfer.files, folderPath || undefined);
@@ -788,12 +184,10 @@ export const FileTree = memo(function FileTree({
788
184
  setDragOverFolder(null);
789
185
  return;
790
186
  }
791
- // Extract filename from source path
792
187
  const fileName = sourcePath.includes("/")
793
188
  ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1)
794
189
  : sourcePath;
795
190
  const newPath = folderPath ? `${folderPath}/${fileName}` : fileName;
796
- // Don't move to same location or into own subtree
797
191
  if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) {
798
192
  onMoveFile(sourcePath, newPath);
799
193
  }
@@ -812,7 +206,6 @@ export const FileTree = memo(function FileTree({
812
206
  const handleRootContextMenu = useCallback(
813
207
  (e: React.MouseEvent) => {
814
208
  if (!hasFileOps) return;
815
- // Only trigger if clicking directly on the container, not on a file/folder button
816
209
  if (e.target === e.currentTarget) {
817
210
  e.preventDefault();
818
211
  setContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetIsFolder: true });
@@ -857,7 +250,6 @@ export const FileTree = memo(function FileTree({
857
250
  onContextMenu={handleRootContextMenu}
858
251
  onDragOver={(e) => {
859
252
  e.preventDefault();
860
- // Show root highlight when dragging over the background (not a child folder)
861
253
  if (e.target === e.currentTarget) setDragOverFolder("");
862
254
  }}
863
255
  onDragLeave={(e) => {