@hyperframes/studio 0.6.0-alpha.9 → 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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,128 @@
1
+ import {
2
+ FileHtml,
3
+ FileCss,
4
+ FileJs,
5
+ FileJsx,
6
+ FileTs,
7
+ FileTsx,
8
+ FileTxt,
9
+ FileMd,
10
+ FileSvg,
11
+ FilePng,
12
+ FileJpg,
13
+ FileVideo,
14
+ FileCode,
15
+ File,
16
+ Waveform,
17
+ TextAa,
18
+ Image as PhImage,
19
+ } from "@phosphor-icons/react";
20
+
21
+ const SZ = 14;
22
+ const W = "duotone" as const;
23
+
24
+ export function FileIcon({ path }: { path: string }) {
25
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
26
+ const c = "flex-shrink-0";
27
+ if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
28
+ if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
29
+ if (ext === "js" || ext === "mjs" || ext === "cjs")
30
+ return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
31
+ if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
32
+ if (ext === "ts" || ext === "mts")
33
+ return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
34
+ if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
35
+ if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
36
+ if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
37
+ if (ext === "md" || ext === "mdx")
38
+ return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
39
+ if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
40
+ if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
41
+ if (ext === "jpg" || ext === "jpeg")
42
+ return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
43
+ if (ext === "webp" || ext === "gif" || ext === "ico")
44
+ return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
45
+ if (ext === "mp4" || ext === "webm" || ext === "mov")
46
+ return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
47
+ if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
48
+ return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
49
+ if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
50
+ return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
51
+ return <File size={SZ} weight={W} color="#6B7280" className={c} />;
52
+ }
53
+
54
+ // ── Tree Types ──
55
+
56
+ export interface TreeNode {
57
+ name: string;
58
+ fullPath: string;
59
+ children: Map<string, TreeNode>;
60
+ isFile: boolean;
61
+ }
62
+
63
+ export interface ContextMenuState {
64
+ x: number;
65
+ y: number;
66
+ targetPath: string;
67
+ targetIsFolder: boolean;
68
+ }
69
+
70
+ export interface InlineInputState {
71
+ /** Parent folder path (empty string for root) */
72
+ parentPath: string;
73
+ /** "file" or "folder" creation, or "rename" */
74
+ mode: "new-file" | "new-folder" | "rename";
75
+ /** For rename mode, the original full path */
76
+ originalPath?: string;
77
+ /** For rename mode, the original name */
78
+ originalName?: string;
79
+ onCommit?: (name: string) => void;
80
+ onCancel?: () => void;
81
+ }
82
+
83
+ // ── Tree Helpers ──
84
+
85
+ export function buildTree(files: string[]): TreeNode {
86
+ const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
87
+ for (const file of files) {
88
+ const parts = file.split("/");
89
+ let current = root;
90
+ for (let i = 0; i < parts.length; i++) {
91
+ const part = parts[i];
92
+ const isLast = i === parts.length - 1;
93
+ const fullPath = parts.slice(0, i + 1).join("/");
94
+ if (!current.children.has(part)) {
95
+ current.children.set(part, {
96
+ name: part,
97
+ fullPath,
98
+ children: new Map(),
99
+ isFile: isLast,
100
+ });
101
+ }
102
+ current = current.children.get(part)!;
103
+ if (isLast) current.isFile = true;
104
+ }
105
+ }
106
+ return root;
107
+ }
108
+
109
+ export function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
110
+ return Array.from(children.values()).sort((a, b) => {
111
+ // index.html always first
112
+ if (a.name === "index.html") return -1;
113
+ if (b.name === "index.html") return 1;
114
+ // Directories before files
115
+ if (!a.isFile && b.isFile) return -1;
116
+ if (a.isFile && !b.isFile) return 1;
117
+ return a.name.localeCompare(b.name);
118
+ });
119
+ }
120
+
121
+ export function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
122
+ if (!activeFile) return false;
123
+ if (node.fullPath === activeFile) return true;
124
+ for (const child of node.children.values()) {
125
+ if (isActiveInSubtree(child, activeFile)) return true;
126
+ }
127
+ return false;
128
+ }
@@ -0,0 +1,496 @@
1
+ import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react";
2
+ import {
3
+ PencilSimple,
4
+ Copy,
5
+ Trash,
6
+ FilePlus,
7
+ FolderSimplePlus,
8
+ FolderSimple,
9
+ } from "@phosphor-icons/react";
10
+ import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
11
+ import {
12
+ FileIcon,
13
+ buildTree as _buildTree,
14
+ sortChildren,
15
+ isActiveInSubtree,
16
+ type TreeNode,
17
+ type ContextMenuState,
18
+ type InlineInputState,
19
+ } from "./FileTreeIcons";
20
+
21
+ // Re-export for FileTree.tsx consumers
22
+ export type { TreeNode, ContextMenuState, InlineInputState };
23
+ export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
24
+
25
+ const SZ_ICON = 14;
26
+
27
+ // ── Context Menu Component ──
28
+
29
+ export function ContextMenu({
30
+ state,
31
+ onClose,
32
+ onNewFile,
33
+ onNewFolder,
34
+ onRename,
35
+ onDuplicate,
36
+ onDelete,
37
+ }: {
38
+ state: ContextMenuState;
39
+ onClose: () => void;
40
+ onNewFile: (parentPath: string) => void;
41
+ onNewFolder: (parentPath: string) => void;
42
+ onRename: (path: string) => void;
43
+ onDuplicate: (path: string) => void;
44
+ onDelete: (path: string) => void;
45
+ }) {
46
+ const menuRef = useRef<HTMLDivElement>(null);
47
+
48
+ // eslint-disable-next-line no-restricted-syntax
49
+ useEffect(() => {
50
+ const handleClickOutside = (e: MouseEvent) => {
51
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
52
+ onClose();
53
+ }
54
+ };
55
+ const handleEscape = (e: KeyboardEvent) => {
56
+ if (e.key === "Escape") onClose();
57
+ };
58
+ document.addEventListener("mousedown", handleClickOutside);
59
+ document.addEventListener("keydown", handleEscape);
60
+ return () => {
61
+ document.removeEventListener("mousedown", handleClickOutside);
62
+ document.removeEventListener("keydown", handleEscape);
63
+ };
64
+ }, [onClose]);
65
+
66
+ const adjustedX = Math.min(state.x, window.innerWidth - 180);
67
+ const adjustedY = Math.min(state.y, window.innerHeight - 200);
68
+
69
+ const parentPath = state.targetIsFolder
70
+ ? state.targetPath
71
+ : state.targetPath.includes("/")
72
+ ? state.targetPath.slice(0, state.targetPath.lastIndexOf("/"))
73
+ : "";
74
+
75
+ return (
76
+ <div
77
+ ref={menuRef}
78
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
79
+ style={{ left: adjustedX, top: adjustedY }}
80
+ >
81
+ {state.targetIsFolder && (
82
+ <>
83
+ <button
84
+ 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"
85
+ onClick={() => {
86
+ onNewFile(state.targetPath);
87
+ onClose();
88
+ }}
89
+ >
90
+ <FilePlus size={12} weight="duotone" className="text-neutral-500" />
91
+ New File
92
+ </button>
93
+ <button
94
+ 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"
95
+ onClick={() => {
96
+ onNewFolder(state.targetPath);
97
+ onClose();
98
+ }}
99
+ >
100
+ <FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
101
+ New Folder
102
+ </button>
103
+ <div className="border-t border-neutral-700 my-1" />
104
+ </>
105
+ )}
106
+ {!state.targetIsFolder && (
107
+ <>
108
+ <button
109
+ 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"
110
+ onClick={() => {
111
+ onNewFile(parentPath);
112
+ onClose();
113
+ }}
114
+ >
115
+ <FilePlus size={12} weight="duotone" className="text-neutral-500" />
116
+ New File
117
+ </button>
118
+ <div className="border-t border-neutral-700 my-1" />
119
+ </>
120
+ )}
121
+ <button
122
+ 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"
123
+ onClick={() => {
124
+ onRename(state.targetPath);
125
+ onClose();
126
+ }}
127
+ >
128
+ <PencilSimple size={12} weight="duotone" className="text-neutral-500" />
129
+ Rename
130
+ </button>
131
+ {!state.targetIsFolder && (
132
+ <button
133
+ 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"
134
+ onClick={() => {
135
+ onDuplicate(state.targetPath);
136
+ onClose();
137
+ }}
138
+ >
139
+ <Copy size={12} weight="duotone" className="text-neutral-500" />
140
+ Duplicate
141
+ </button>
142
+ )}
143
+ <div className="border-t border-neutral-700 my-1" />
144
+ <button
145
+ 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"
146
+ onClick={() => {
147
+ onDelete(state.targetPath);
148
+ onClose();
149
+ }}
150
+ >
151
+ <Trash size={12} weight="duotone" />
152
+ Delete
153
+ </button>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ // ── Inline Input (for new file/folder/rename) ──
159
+
160
+ export function InlineInput({
161
+ defaultValue,
162
+ depth,
163
+ isFolder,
164
+ onCommit,
165
+ onCancel,
166
+ }: {
167
+ defaultValue: string;
168
+ depth: number;
169
+ isFolder: boolean;
170
+ onCommit: (value: string) => void;
171
+ onCancel: () => void;
172
+ }) {
173
+ const inputRef = useRef<HTMLInputElement>(null);
174
+ const committedRef = useRef(false);
175
+ const [value, setValue] = useState(defaultValue);
176
+
177
+ // eslint-disable-next-line no-restricted-syntax
178
+ useEffect(() => {
179
+ const el = inputRef.current;
180
+ if (!el) return;
181
+ el.focus();
182
+ if (defaultValue && defaultValue.includes(".")) {
183
+ const dotIdx = defaultValue.lastIndexOf(".");
184
+ el.setSelectionRange(0, dotIdx);
185
+ } else {
186
+ el.select();
187
+ }
188
+ }, [defaultValue]);
189
+
190
+ const commit = (name: string) => {
191
+ if (committedRef.current) return;
192
+ committedRef.current = true;
193
+ onCommit(name);
194
+ };
195
+
196
+ const handleKeyDown = (e: React.KeyboardEvent) => {
197
+ if (e.key === "Enter") {
198
+ e.preventDefault();
199
+ const trimmed = value.trim();
200
+ if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
201
+ else onCancel();
202
+ } else if (e.key === "Escape") {
203
+ e.preventDefault();
204
+ onCancel();
205
+ }
206
+ };
207
+
208
+ const handleBlur = () => {
209
+ const trimmed = value.trim();
210
+ if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
211
+ commit(trimmed);
212
+ else onCancel();
213
+ };
214
+
215
+ return (
216
+ <div
217
+ className="flex items-center gap-2 py-0.5 min-h-7"
218
+ style={{ paddingLeft: `${8 + depth * 12 + (isFolder ? 0 : 14)}px` }}
219
+ >
220
+ {isFolder ? (
221
+ <FolderSimple size={SZ_ICON} weight="duotone" color="#6B7280" className="flex-shrink-0" />
222
+ ) : (
223
+ <FileIcon path={value} />
224
+ )}
225
+ <input
226
+ ref={inputRef}
227
+ value={value}
228
+ onChange={(e) => setValue(e.target.value)}
229
+ onKeyDown={handleKeyDown}
230
+ onBlur={handleBlur}
231
+ 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]"
232
+ spellCheck={false}
233
+ />
234
+ </div>
235
+ );
236
+ }
237
+
238
+ // ── Delete Confirmation ──
239
+
240
+ export function DeleteConfirm({
241
+ name,
242
+ onConfirm,
243
+ onCancel,
244
+ }: {
245
+ name: string;
246
+ onConfirm: () => void;
247
+ onCancel: () => void;
248
+ }) {
249
+ const ref = useRef<HTMLDivElement>(null);
250
+
251
+ // eslint-disable-next-line no-restricted-syntax
252
+ useEffect(() => {
253
+ const handleEscape = (e: KeyboardEvent) => {
254
+ if (e.key === "Escape") onCancel();
255
+ };
256
+ const handleClickOutside = (e: MouseEvent) => {
257
+ if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
258
+ };
259
+ document.addEventListener("keydown", handleEscape);
260
+ document.addEventListener("mousedown", handleClickOutside);
261
+ return () => {
262
+ document.removeEventListener("keydown", handleEscape);
263
+ document.removeEventListener("mousedown", handleClickOutside);
264
+ };
265
+ }, [onCancel]);
266
+
267
+ return (
268
+ <div
269
+ ref={ref}
270
+ className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
271
+ >
272
+ <p className="text-neutral-300 mb-2">
273
+ Delete <span className="font-medium text-neutral-100">{name}</span>?
274
+ </p>
275
+ <div className="flex gap-1.5">
276
+ <button
277
+ onClick={onCancel}
278
+ className="flex-1 px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 transition-colors"
279
+ >
280
+ Cancel
281
+ </button>
282
+ <button
283
+ onClick={onConfirm}
284
+ className="flex-1 px-2 py-1 rounded bg-red-900/60 text-red-300 hover:bg-red-800/60 transition-colors"
285
+ >
286
+ Delete
287
+ </button>
288
+ </div>
289
+ </div>
290
+ );
291
+ }
292
+
293
+ // ── TreeFile ──
294
+
295
+ export const TreeFile = memo(function TreeFile({
296
+ node,
297
+ depth,
298
+ activeFile,
299
+ onSelectFile,
300
+ onContextMenu,
301
+ inlineInput,
302
+ onDragStart,
303
+ }: {
304
+ node: TreeNode;
305
+ depth: number;
306
+ activeFile: string | null;
307
+ onSelectFile: (path: string) => void;
308
+ onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
309
+ inlineInput: InlineInputState | null;
310
+ onDragStart: (e: React.DragEvent, path: string) => void;
311
+ }) {
312
+ const isActive = node.fullPath === activeFile;
313
+ const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
314
+
315
+ if (isRenaming) {
316
+ return (
317
+ <InlineInput
318
+ defaultValue={inlineInput.originalName ?? node.name}
319
+ depth={depth}
320
+ isFolder={false}
321
+ onCommit={(name) => {
322
+ inlineInput?.onCommit?.(name);
323
+ }}
324
+ onCancel={() => {
325
+ inlineInput?.onCancel?.();
326
+ }}
327
+ />
328
+ );
329
+ }
330
+
331
+ return (
332
+ <button
333
+ draggable
334
+ onDragStart={(e) => onDragStart(e, node.fullPath)}
335
+ onClick={() => onSelectFile(node.fullPath)}
336
+ onContextMenu={(e) => {
337
+ e.preventDefault();
338
+ onContextMenu(e, node.fullPath, false);
339
+ }}
340
+ className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
341
+ isActive
342
+ ? "bg-neutral-800/60 text-neutral-200"
343
+ : "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
344
+ }`}
345
+ style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
346
+ >
347
+ <FileIcon path={node.name} />
348
+ <span className="truncate">{node.name}</span>
349
+ </button>
350
+ );
351
+ });
352
+
353
+ // ── TreeFolder ──
354
+
355
+ export const TreeFolder = memo(function TreeFolder({
356
+ node,
357
+ depth,
358
+ activeFile,
359
+ onSelectFile,
360
+ defaultOpen,
361
+ onContextMenu,
362
+ inlineInput,
363
+ onDragStart,
364
+ onDragOver,
365
+ onDrop,
366
+ onDragLeave,
367
+ dragOverFolder,
368
+ }: {
369
+ node: TreeNode;
370
+ depth: number;
371
+ activeFile: string | null;
372
+ onSelectFile: (path: string) => void;
373
+ defaultOpen: boolean;
374
+ onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
375
+ inlineInput: InlineInputState | null;
376
+ onDragStart: (e: React.DragEvent, path: string) => void;
377
+ onDragOver: (e: React.DragEvent, folderPath: string) => void;
378
+ onDrop: (e: React.DragEvent, folderPath: string) => void;
379
+ onDragLeave: () => void;
380
+ dragOverFolder: string | null;
381
+ }) {
382
+ const [isOpen, setIsOpen] = useState(defaultOpen);
383
+ const toggle = useCallback(() => setIsOpen((v) => !v), []);
384
+ const children = useMemo(() => sortChildren(node.children), [node.children]);
385
+ const Chevron = isOpen ? ChevronDown : ChevronRight;
386
+ const isDragOver = dragOverFolder === node.fullPath;
387
+ const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
388
+
389
+ if (isRenaming) {
390
+ return (
391
+ <InlineInput
392
+ defaultValue={inlineInput.originalName ?? node.name}
393
+ depth={depth}
394
+ isFolder={true}
395
+ onCommit={(name) => {
396
+ inlineInput?.onCommit?.(name);
397
+ }}
398
+ onCancel={() => {
399
+ inlineInput?.onCancel?.();
400
+ }}
401
+ />
402
+ );
403
+ }
404
+
405
+ return (
406
+ <>
407
+ <button
408
+ draggable
409
+ onDragStart={(e) => onDragStart(e, node.fullPath)}
410
+ onClick={toggle}
411
+ onContextMenu={(e) => {
412
+ e.preventDefault();
413
+ onContextMenu(e, node.fullPath, true);
414
+ }}
415
+ onDragOver={(e) => {
416
+ e.preventDefault();
417
+ e.stopPropagation();
418
+ onDragOver(e, node.fullPath);
419
+ }}
420
+ onDrop={(e) => {
421
+ e.preventDefault();
422
+ e.stopPropagation();
423
+ onDrop(e, node.fullPath);
424
+ }}
425
+ onDragLeave={onDragLeave}
426
+ 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 ${
427
+ isDragOver ? "bg-[#3CE6AC]/10 outline outline-1 outline-[#3CE6AC]/40" : ""
428
+ }`}
429
+ style={{ paddingLeft: `${8 + depth * 12}px` }}
430
+ >
431
+ <Chevron size={10} className="flex-shrink-0 text-neutral-600" />
432
+ <span className="truncate font-medium">{node.name}</span>
433
+ </button>
434
+ {isOpen && (
435
+ <>
436
+ {inlineInput &&
437
+ (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
438
+ inlineInput.parentPath === node.fullPath && (
439
+ <InlineInput
440
+ defaultValue=""
441
+ depth={depth + 1}
442
+ isFolder={inlineInput.mode === "new-folder"}
443
+ onCommit={(name) => {
444
+ inlineInput?.onCommit?.(name);
445
+ }}
446
+ onCancel={() => {
447
+ inlineInput?.onCancel?.();
448
+ }}
449
+ />
450
+ )}
451
+ {children.map((child) =>
452
+ child.isFile && child.children.size === 0 ? (
453
+ <TreeFile
454
+ key={child.fullPath}
455
+ node={child}
456
+ depth={depth + 1}
457
+ activeFile={activeFile}
458
+ onSelectFile={onSelectFile}
459
+ onContextMenu={onContextMenu}
460
+ inlineInput={inlineInput}
461
+ onDragStart={onDragStart}
462
+ />
463
+ ) : child.children.size > 0 ? (
464
+ <TreeFolder
465
+ key={child.fullPath}
466
+ node={child}
467
+ depth={depth + 1}
468
+ activeFile={activeFile}
469
+ onSelectFile={onSelectFile}
470
+ defaultOpen={isActiveInSubtree(child, activeFile)}
471
+ onContextMenu={onContextMenu}
472
+ inlineInput={inlineInput}
473
+ onDragStart={onDragStart}
474
+ onDragOver={onDragOver}
475
+ onDrop={onDrop}
476
+ onDragLeave={onDragLeave}
477
+ dragOverFolder={dragOverFolder}
478
+ />
479
+ ) : (
480
+ <TreeFile
481
+ key={child.fullPath}
482
+ node={child}
483
+ depth={depth + 1}
484
+ activeFile={activeFile}
485
+ onSelectFile={onSelectFile}
486
+ onContextMenu={onContextMenu}
487
+ inlineInput={inlineInput}
488
+ onDragStart={onDragStart}
489
+ />
490
+ ),
491
+ )}
492
+ </>
493
+ )}
494
+ </>
495
+ );
496
+ });