mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install.
337 lines
9.9 KiB
TypeScript
337 lines
9.9 KiB
TypeScript
import { memo, useState, useCallback, useMemo, useRef } from "react";
|
|
import { Plus, FolderSimplePlus } from "@phosphor-icons/react";
|
|
import {
|
|
buildTree,
|
|
sortChildren,
|
|
isActiveInSubtree,
|
|
ContextMenu,
|
|
InlineInput,
|
|
DeleteConfirm,
|
|
TreeFile,
|
|
TreeFolder,
|
|
type ContextMenuState,
|
|
type InlineInputState,
|
|
} from "./FileTreeNodes";
|
|
|
|
// ── Types ──
|
|
|
|
export interface FileTreeProps {
|
|
files: string[];
|
|
activeFile: string | null;
|
|
onSelectFile: (path: string) => void;
|
|
onCreateFile?: (path: string) => void;
|
|
onCreateFolder?: (path: string) => void;
|
|
onDeleteFile?: (path: string) => void;
|
|
onRenameFile?: (oldPath: string, newPath: string) => void;
|
|
onDuplicateFile?: (path: string) => void;
|
|
onMoveFile?: (oldPath: string, newPath: string) => void;
|
|
onImportFiles?: (files: FileList, dir?: string) => void;
|
|
}
|
|
|
|
// ── Main FileTree Component ──
|
|
|
|
export const FileTree = memo(function FileTree({
|
|
files,
|
|
activeFile,
|
|
onSelectFile,
|
|
onCreateFile,
|
|
onCreateFolder,
|
|
onDeleteFile,
|
|
onRenameFile,
|
|
onDuplicateFile,
|
|
onMoveFile,
|
|
onImportFiles,
|
|
}: FileTreeProps) {
|
|
const tree = useMemo(() => buildTree(files), [files]);
|
|
const children = useMemo(() => sortChildren(tree.children), [tree]);
|
|
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
|
const dragSourceRef = useRef<string | null>(null);
|
|
|
|
const hasFileOps = !!(
|
|
onCreateFile ||
|
|
onCreateFolder ||
|
|
onDeleteFile ||
|
|
onRenameFile ||
|
|
onDuplicateFile
|
|
);
|
|
|
|
// ── Context Menu handlers ──
|
|
|
|
const handleContextMenu = useCallback(
|
|
(e: React.MouseEvent, path: string, isFolder: boolean) => {
|
|
if (!hasFileOps) return;
|
|
e.preventDefault();
|
|
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: path, targetIsFolder: isFolder });
|
|
},
|
|
[hasFileOps],
|
|
);
|
|
|
|
const handleCloseContextMenu = useCallback(() => setContextMenu(null), []);
|
|
|
|
// ── New File ──
|
|
|
|
const handleNewFile = useCallback(
|
|
(parentPath: string) => {
|
|
setInlineInput({
|
|
parentPath,
|
|
mode: "new-file",
|
|
onCommit: (name: string) => {
|
|
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
|
onCreateFile?.(fullPath);
|
|
setInlineInput(null);
|
|
},
|
|
onCancel: () => setInlineInput(null),
|
|
});
|
|
},
|
|
[onCreateFile],
|
|
);
|
|
|
|
// ── New Folder ──
|
|
|
|
const handleNewFolder = useCallback(
|
|
(parentPath: string) => {
|
|
setInlineInput({
|
|
parentPath,
|
|
mode: "new-folder",
|
|
onCommit: (name: string) => {
|
|
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
|
onCreateFolder?.(fullPath);
|
|
setInlineInput(null);
|
|
},
|
|
onCancel: () => setInlineInput(null),
|
|
});
|
|
},
|
|
[onCreateFolder],
|
|
);
|
|
|
|
// ── Rename ──
|
|
|
|
const handleRename = useCallback(
|
|
(path: string) => {
|
|
const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path;
|
|
const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
setInlineInput({
|
|
parentPath,
|
|
mode: "rename",
|
|
originalPath: path,
|
|
originalName: name,
|
|
onCommit: (newName: string) => {
|
|
if (newName !== name) {
|
|
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
|
onRenameFile?.(path, newPath);
|
|
}
|
|
setInlineInput(null);
|
|
},
|
|
onCancel: () => setInlineInput(null),
|
|
});
|
|
},
|
|
[onRenameFile],
|
|
);
|
|
|
|
// ── Duplicate ──
|
|
|
|
const handleDuplicate = useCallback(
|
|
(path: string) => {
|
|
onDuplicateFile?.(path);
|
|
},
|
|
[onDuplicateFile],
|
|
);
|
|
|
|
// ── Delete ──
|
|
|
|
const handleDelete = useCallback((path: string) => {
|
|
setDeleteTarget(path);
|
|
}, []);
|
|
|
|
const handleDeleteConfirm = useCallback(() => {
|
|
if (deleteTarget) {
|
|
onDeleteFile?.(deleteTarget);
|
|
setDeleteTarget(null);
|
|
}
|
|
}, [deleteTarget, onDeleteFile]);
|
|
|
|
const handleDeleteCancel = useCallback(() => {
|
|
setDeleteTarget(null);
|
|
}, []);
|
|
|
|
// ── Drag and Drop ──
|
|
|
|
const handleDragStart = useCallback((e: React.DragEvent, path: string) => {
|
|
dragSourceRef.current = path;
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", path);
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((_e: React.DragEvent, folderPath: string) => {
|
|
setDragOverFolder(folderPath);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent, folderPath: string) => {
|
|
if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) {
|
|
e.preventDefault();
|
|
onImportFiles?.(e.dataTransfer.files, folderPath || undefined);
|
|
setDragOverFolder(null);
|
|
return;
|
|
}
|
|
|
|
const sourcePath = dragSourceRef.current;
|
|
if (!sourcePath || !onMoveFile) {
|
|
setDragOverFolder(null);
|
|
return;
|
|
}
|
|
const fileName = sourcePath.includes("/")
|
|
? sourcePath.slice(sourcePath.lastIndexOf("/") + 1)
|
|
: sourcePath;
|
|
const newPath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
|
if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) {
|
|
onMoveFile(sourcePath, newPath);
|
|
}
|
|
setDragOverFolder(null);
|
|
dragSourceRef.current = null;
|
|
},
|
|
[onMoveFile, onImportFiles],
|
|
);
|
|
|
|
const handleDragLeave = useCallback(() => {
|
|
setDragOverFolder(null);
|
|
}, []);
|
|
|
|
// ── Root-level context menu (right-click on empty space) ──
|
|
|
|
const handleRootContextMenu = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!hasFileOps) return;
|
|
if (e.target === e.currentTarget) {
|
|
e.preventDefault();
|
|
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetIsFolder: true });
|
|
}
|
|
},
|
|
[hasFileOps],
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
{/* FILES header with action buttons */}
|
|
{hasFileOps && (
|
|
<div className="flex items-center justify-between px-2.5 py-1.5 border-b border-neutral-800/50 flex-shrink-0">
|
|
<span className="text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
|
|
Files
|
|
</span>
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
onClick={() => handleNewFile("")}
|
|
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
|
title="New File"
|
|
>
|
|
<Plus size={12} weight="bold" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleNewFolder("")}
|
|
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
|
title="New Folder"
|
|
>
|
|
<FolderSimplePlus size={12} weight="duotone" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={`flex-1 overflow-y-auto py-1 transition-colors ${
|
|
dragOverFolder === ""
|
|
? "bg-[#3CE6AC]/5 outline outline-1 outline-[#3CE6AC]/30 -outline-offset-1"
|
|
: ""
|
|
}`}
|
|
onContextMenu={handleRootContextMenu}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (e.target === e.currentTarget) setDragOverFolder("");
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (e.target === e.currentTarget) setDragOverFolder(null);
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
handleDrop(e, "");
|
|
}}
|
|
>
|
|
{/* Root-level inline input for new file/folder */}
|
|
{inlineInput &&
|
|
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
|
inlineInput.parentPath === "" && (
|
|
<InlineInput
|
|
defaultValue=""
|
|
depth={0}
|
|
isFolder={inlineInput.mode === "new-folder"}
|
|
onCommit={(name) => inlineInput.onCommit?.(name)}
|
|
onCancel={() => inlineInput.onCancel?.()}
|
|
/>
|
|
)}
|
|
{children.map((child) =>
|
|
child.isFile && child.children.size === 0 ? (
|
|
<TreeFile
|
|
key={child.fullPath}
|
|
node={child}
|
|
depth={0}
|
|
activeFile={activeFile}
|
|
onSelectFile={onSelectFile}
|
|
onContextMenu={handleContextMenu}
|
|
inlineInput={inlineInput}
|
|
onDragStart={handleDragStart}
|
|
/>
|
|
) : (
|
|
<TreeFolder
|
|
key={child.fullPath}
|
|
node={child}
|
|
depth={0}
|
|
activeFile={activeFile}
|
|
onSelectFile={onSelectFile}
|
|
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
onContextMenu={handleContextMenu}
|
|
inlineInput={inlineInput}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
onDragLeave={handleDragLeave}
|
|
dragOverFolder={dragOverFolder}
|
|
/>
|
|
),
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete confirmation overlay */}
|
|
{deleteTarget && (
|
|
<div className="border-t border-neutral-800/50 flex-shrink-0">
|
|
<DeleteConfirm
|
|
name={
|
|
deleteTarget.includes("/")
|
|
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
|
|
: deleteTarget
|
|
}
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={handleDeleteCancel}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Context menu */}
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
state={contextMenu}
|
|
onClose={handleCloseContextMenu}
|
|
onNewFile={handleNewFile}
|
|
onNewFolder={handleNewFolder}
|
|
onRename={handleRename}
|
|
onDuplicate={handleDuplicate}
|
|
onDelete={handleDelete}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|